prettier fixes #16

This commit is contained in:
Michael Dausmann
2023-10-24 21:18:03 +11:00
parent dc9d64ebf5
commit a7f8c37f99
56 changed files with 1706 additions and 935 deletions

16
.prettierignore Normal file
View File

@@ -0,0 +1,16 @@
CHANGELOG.md
LICENSE
package.json
package-lock.json
node_modules
*.log*
.nuxt
.nitro
.cache
.output
.env
.env_example
dist
junk
prisma/schema.prisma
assets

7
.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"singleQuote": true,
"bracketSameLine": true,
"vueIndentScriptAndStyle": true,
"arrowParens": "avoid",
"trailingComma": "none"
}

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

View File

@@ -1,7 +1,9 @@
<template> <template>
<div class="sticky z-50 bottom-0 p-4 bg-base-200"> <div class="sticky z-50 bottom-0 p-4 bg-base-200">
<span><NuxtLink to="/terms">Terms Of Service</NuxtLink></span> <NuxtLink to="/terms">Terms Of Service</NuxtLink>
<span>&nbsp;|&nbsp;<NuxtLink to="/privacy">Privacy</NuxtLink></span> <span class="px-2">|</span>
<span>&nbsp;|&nbsp;<button type="button" data-cc="c-settings">Cookie settings</button></span> <NuxtLink to="/privacy">Privacy</NuxtLink>
<span class="px-2">|</span>
<button type="button" data-cc="c-settings">Cookie settings</button>
</div> </div>
</template> </template>

View File

@@ -4,19 +4,34 @@
<template> <template>
<div class="navbar bg-base-100"> <div class="navbar bg-base-100">
<Notifications/> <Notifications />
<div class="navbar-start"> <div class="navbar-start">
<div class="dropdown"> <div class="dropdown">
<label tabindex="0" class="btn btn-ghost lg:hidden"> <label tabindex="0" class="btn btn-ghost lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg> <svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h8m-8 6h16" />
</svg>
</label> </label>
<ul tabindex="0" class="menu menu-compact dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-52"> <ul
tabindex="0"
class="menu menu-compact dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-52">
<li v-if="user"><NuxtLink to="/dashboard">Dashboard</NuxtLink></li> <li v-if="user"><NuxtLink to="/dashboard">Dashboard</NuxtLink></li>
<li><NuxtLink to="/pricing">Pricing</NuxtLink></li> <li><NuxtLink to="/pricing">Pricing</NuxtLink></li>
<li v-if="!user"><NuxtLink to="/signin">Sign In</NuxtLink></li> <li v-if="!user"><NuxtLink to="/signin">Sign In</NuxtLink></li>
</ul> </ul>
</div> </div>
<NuxtLink to="/" class="btn btn-ghost normal-case text-xl">SupaNuxt SAAS</NuxtLink> <NuxtLink to="/" class="btn btn-ghost normal-case text-xl">
SupaNuxt SAAS
</NuxtLink>
</div> </div>
<div class="navbar-center hidden lg:flex"> <div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1"> <ul class="menu menu-horizontal px-1">
@@ -24,9 +39,15 @@
<li><NuxtLink to="/pricing">Pricing</NuxtLink></li> <li><NuxtLink to="/pricing">Pricing</NuxtLink></li>
<li v-if="!user"><NuxtLink to="/signin">Sign In</NuxtLink></li> <li v-if="!user"><NuxtLink to="/signin">Sign In</NuxtLink></li>
<li v-if="!user"><NuxtLink to="/signup">Start for free</NuxtLink></li> <li v-if="!user"><NuxtLink to="/signup">Start for free</NuxtLink></li>
<li v-if="!user"><a title="github" href="https://github.com/JavascriptMick/supanuxt-saas"><Icon name="mdi:github"/></a></li> <li v-if="!user">
<a
title="github"
href="https://github.com/JavascriptMick/supanuxt-saas">
<Icon name="mdi:github" />
</a>
</li>
</ul> </ul>
</div> </div>
<UserAccount v-if="user" :user="user"/> <UserAccount v-if="user" :user="user" />
</div> </div>
</template> </template>

View File

@@ -21,18 +21,18 @@
// props for which buttons to show // props for which buttons to show
interface Props { interface Props {
showOk?: boolean showOk?: boolean;
showCancel?: boolean showCancel?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
showOk: true, showOk: true,
showCancel: false, showCancel: false
}) });
// open event (exposed to parent) // open event (exposed to parent)
const open = () => { const open = () => {
modalIsVisible.value = true; modalIsVisible.value = true;
} };
defineExpose({ open }); defineExpose({ open });
// close events emitted on modal close // close events emitted on modal close
@@ -40,16 +40,20 @@
const closeOk = () => { const closeOk = () => {
emit('closeOk'); emit('closeOk');
modalIsVisible.value = false; modalIsVisible.value = false;
} };
const closeCancel = () => { const closeCancel = () => {
emit('closeCancel'); emit('closeCancel');
modalIsVisible.value = false; modalIsVisible.value = false;
} };
</script> </script>
<template> <template>
<!-- the input controls the visibility of the modal (css shenanigans) the v-model allows me to control it in turn from the wrapper component --> <!-- the input controls the visibility of the modal (css shenanigans) the v-model allows me to control it in turn from the wrapper component -->
<input type="checkbox" id="my-modal" class="modal-toggle" v-model="modalIsVisible" /> <input
type="checkbox"
id="my-modal"
class="modal-toggle"
v-model="modalIsVisible" />
<div class="modal"> <div class="modal">
<div class="modal-box"> <div class="modal-box">
<slot /> <slot />

View File

@@ -1,4 +1,3 @@
<script setup lang="ts"> <script setup lang="ts">
import { NotificationType } from '#imports'; import { NotificationType } from '#imports';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -9,20 +8,21 @@
const classNameForType = (type: NotificationType) => { const classNameForType = (type: NotificationType) => {
switch (type) { switch (type) {
case NotificationType.Info: case NotificationType.Info:
return "alert alert-info"; return 'alert alert-info';
case NotificationType.Success: case NotificationType.Success:
return "alert alert-success"; return 'alert alert-success';
case NotificationType.Warning: case NotificationType.Warning:
return "alert alert-warning"; return 'alert alert-warning';
case NotificationType.Error: case NotificationType.Error:
return "alert alert-error"; return 'alert alert-error';
} }
}; };
</script> </script>
<template> <template>
<div class="toast toast-end toast-top"> <div class="toast toast-end toast-top">
<div v-for="notification in notifications" :class="classNameForType(notification.type)" > <div
v-for="notification in notifications"
:class="classNameForType(notification.type)">
<div> <div>
<button <button
@click.prevent="notifyStore.removeNotification(notification)" @click.prevent="notifyStore.removeNotification(notification)"
@@ -30,9 +30,19 @@
class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700"
aria-label="Close"> aria-label="Close">
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
<svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg> <svg
aria-hidden="true"
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"></path>
</svg>
</button> </button>
<span>&nbsp;{{notification.message}}</span> <span>&nbsp;{{ notification.message }}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,8 +2,8 @@
const props = defineProps({ const props = defineProps({
user: { user: {
type: Object, type: Object,
required: true, required: true
}, }
}); });
const { user } = props; const { user } = props;
</script> </script>
@@ -13,15 +13,23 @@
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-circle avatar"> <label tabindex="0" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full"> <div class="w-10 rounded-full">
<img v-if="user.user_metadata?.avatar_url" :src="user.user_metadata.avatar_url" alt="avatar image"/> <img
<img v-else src="~/assets/images/avatar.svg" alt="default avatar image"/> v-if="user.user_metadata?.avatar_url"
:src="user.user_metadata.avatar_url"
alt="avatar image" />
<img
v-else
src="~/assets/images/avatar.svg"
alt="default avatar image" />
</div> </div>
</label> </label>
<ul tabindex="0" class="mt-3 p-2 shadow menu menu-compact dropdown-content bg-base-100 rounded-box w-52"> <ul
tabindex="0"
class="mt-3 p-2 shadow menu menu-compact dropdown-content bg-base-100 rounded-box w-52">
<li v-if="user">{{ user.email }}</li> <li v-if="user">{{ user.email }}</li>
<li><NuxtLink to="/account">Account</NuxtLink></li> <li><NuxtLink to="/account">Account</NuxtLink></li>
<li><UserAccountSignout/></li> <li><UserAccountSignout /></li>
<UserAccountSwitch/> <UserAccountSwitch />
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -1,18 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
const supabase = useSupabaseAuthClient(); const supabase = useSupabaseAuthClient();
const accountStore = useAccountStore(); const accountStore = useAccountStore();
onMounted(async () => { onMounted(async () => {
await accountStore.init() await accountStore.init();
}); });
async function signout() { async function signout() {
await supabase.auth.signOut(); await supabase.auth.signOut();
if(accountStore){ if (accountStore) {
accountStore.signout(); accountStore.signout();
} }
navigateTo('/', {replace: true}); navigateTo('/', { replace: true });
} }
</script> </script>
<template> <template>

View File

@@ -1,21 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
const accountStore = useAccountStore() const accountStore = useAccountStore();
const { dbUser, activeAccountId } = storeToRefs(accountStore); const { dbUser, activeAccountId } = storeToRefs(accountStore);
onMounted(async () => { onMounted(async () => {
await accountStore.init() await accountStore.init();
}); });
</script> </script>
<template> <template>
<template v-if="dbUser?.memberships && dbUser?.memberships.length > 1"> <template v-if="dbUser?.memberships && dbUser?.memberships.length > 1">
<li>Switch Account</li> <li>Switch Account</li>
<li v-for="membership in dbUser?.memberships"> <li v-for="membership in dbUser?.memberships">
<a v-if="membership.account_id !== activeAccountId && !membership.pending" href="#" @click="accountStore.changeActiveAccount(membership.account_id)">{{ membership.account.name }}</a> <a
<span v-if="membership.pending">{{ membership.account.name }} (pending)</span> v-if="membership.account_id !== activeAccountId && !membership.pending"
href="#"
@click="accountStore.changeActiveAccount(membership.account_id)">
{{ membership.account.name }}
</a>
<span v-if="membership.pending">
{{ membership.account.name }} (pending)
</span>
</li> </li>
</template> </template>
</template> </template>

View File

@@ -1,6 +1,13 @@
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum'; import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
import prisma_client from '~~/prisma/prisma.client'; import prisma_client from '~~/prisma/prisma.client';
import { accountWithMembers, AccountWithMembers, membershipWithAccount, MembershipWithAccount, membershipWithUser, MembershipWithUser } from './service.types'; import {
accountWithMembers,
AccountWithMembers,
membershipWithAccount,
MembershipWithAccount,
membershipWithUser,
MembershipWithUser
} from './service.types';
import generator from 'generate-password-ts'; import generator from 'generate-password-ts';
import { UtilService } from './util.service'; import { UtilService } from './util.service';
import { AccountLimitError } from './errors'; import { AccountLimitError } from './errors';
@@ -15,7 +22,9 @@ export default class AccountService {
}); });
} }
async getAccountByJoinPassword(join_password: string): Promise<AccountWithMembers> { async getAccountByJoinPassword(
join_password: string
): Promise<AccountWithMembers> {
return prisma_client.account.findFirstOrThrow({ return prisma_client.account.findFirstOrThrow({
where: { join_password }, where: { join_password },
...accountWithMembers ...accountWithMembers
@@ -29,32 +38,40 @@ export default class AccountService {
}); });
} }
async updateAccountStipeCustomerId (account_id: number, stripe_customer_id: string){ async updateAccountStipeCustomerId(
account_id: number,
stripe_customer_id: string
) {
return await prisma_client.account.update({ return await prisma_client.account.update({
where: { id: account_id }, where: { id: account_id },
data: { data: {
stripe_customer_id, stripe_customer_id
} }
}) });
} }
async updateStripeSubscriptionDetailsForAccount (stripe_customer_id: string, stripe_subscription_id: string, current_period_ends: Date, stripe_product_id: string){ async updateStripeSubscriptionDetailsForAccount(
stripe_customer_id: string,
stripe_subscription_id: string,
current_period_ends: Date,
stripe_product_id: string
) {
const account = await prisma_client.account.findFirstOrThrow({ const account = await prisma_client.account.findFirstOrThrow({
where: {stripe_customer_id} where: { stripe_customer_id }
}); });
const paid_plan = await prisma_client.plan.findFirstOrThrow({ const paid_plan = await prisma_client.plan.findFirstOrThrow({
where: { stripe_product_id }, where: { stripe_product_id }
}); });
if(paid_plan.id == account.plan_id){ if (paid_plan.id == account.plan_id) {
// only update sub and period info // only update sub and period info
return await prisma_client.account.update({ return await prisma_client.account.update({
where: { id: account.id }, where: { id: account.id },
data: { data: {
stripe_subscription_id, stripe_subscription_id,
current_period_ends, current_period_ends,
ai_gen_count:0, ai_gen_count: 0
} }
}); });
} else { } else {
@@ -70,72 +87,82 @@ export default class AccountService {
max_members: paid_plan.max_members, max_members: paid_plan.max_members,
plan_name: paid_plan.name, plan_name: paid_plan.name,
ai_gen_max_pm: paid_plan.ai_gen_max_pm, ai_gen_max_pm: paid_plan.ai_gen_max_pm,
ai_gen_count:0, // I did vacillate on this point ultimately easier to just reset, discussion here https://www.reddit.com/r/SaaS/comments/16e9bew/should_i_reset_usage_counts_on_plan_upgrade/ ai_gen_count: 0 // I did vacillate on this point ultimately easier to just reset, discussion here https://www.reddit.com/r/SaaS/comments/16e9bew/should_i_reset_usage_counts_on_plan_upgrade/
} }
}); });
} }
} }
async acceptPendingMembership(account_id: number, membership_id: number): Promise<MembershipWithAccount> { async acceptPendingMembership(
account_id: number,
membership_id: number
): Promise<MembershipWithAccount> {
const membership = prisma_client.membership.findFirstOrThrow({ const membership = prisma_client.membership.findFirstOrThrow({
where: { where: {
id: membership_id id: membership_id
} }
}); });
if((await membership).account_id != account_id){ if ((await membership).account_id != account_id) {
throw new Error(`Membership does not belong to current account`); throw new Error(`Membership does not belong to current account`);
} }
return await prisma_client.membership.update({ return await prisma_client.membership.update({
where: { where: {
id: membership_id, id: membership_id
}, },
data: { data: {
pending: false pending: false
}, },
...membershipWithAccount ...membershipWithAccount
}) });
} }
async deleteMembership(account_id: number, membership_id: number): Promise<MembershipWithAccount> { async deleteMembership(
account_id: number,
membership_id: number
): Promise<MembershipWithAccount> {
const membership = prisma_client.membership.findFirstOrThrow({ const membership = prisma_client.membership.findFirstOrThrow({
where: { where: {
id: membership_id id: membership_id
} }
}); });
if((await membership).account_id != account_id){ if ((await membership).account_id != account_id) {
throw new Error(`Membership does not belong to current account`); throw new Error(`Membership does not belong to current account`);
} }
return await prisma_client.membership.delete({ return await prisma_client.membership.delete({
where: { where: {
id: membership_id, id: membership_id
}, },
...membershipWithAccount ...membershipWithAccount
}) });
} }
async joinUserToAccount(user_id: number, account_id: number, pending: boolean ): Promise<MembershipWithAccount> { async joinUserToAccount(
user_id: number,
account_id: number,
pending: boolean
): Promise<MembershipWithAccount> {
const account = await prisma_client.account.findUnique({ const account = await prisma_client.account.findUnique({
where: { where: {
id: account_id, id: account_id
}, },
include:{ include: {
members: true, members: true
}
} }
) });
if(account?.members && account?.members?.length >= account?.max_members){ if (account?.members && account?.members?.length >= account?.max_members) {
throw new Error(`Too Many Members, Account only permits ${account?.max_members} members.`); throw new Error(
`Too Many Members, Account only permits ${account?.max_members} members.`
);
} }
if(account?.members){ if (account?.members) {
for(const member of account.members){ for (const member of account.members) {
if(member.user_id === user_id){ if (member.user_id === user_id) {
throw new Error(`User is already a member`); throw new Error(`User is already a member`);
} }
} }
@@ -154,21 +181,23 @@ export default class AccountService {
async changeAccountName(account_id: number, new_name: string) { async changeAccountName(account_id: number, new_name: string) {
return prisma_client.account.update({ return prisma_client.account.update({
where: { id: account_id}, where: { id: account_id },
data: { data: {
name: new_name, name: new_name
} }
}); });
} }
async changeAccountPlan(account_id: number, plan_id: number) { async changeAccountPlan(account_id: number, plan_id: number) {
const plan = await prisma_client.plan.findFirstOrThrow({ where: {id: plan_id}}); const plan = await prisma_client.plan.findFirstOrThrow({
where: { id: plan_id }
});
return prisma_client.account.update({ return prisma_client.account.update({
where: { id: account_id}, where: { id: account_id },
data: { data: {
plan_id: plan_id, plan_id: plan_id,
features: plan.features, features: plan.features,
max_notes: plan.max_notes, max_notes: plan.max_notes
} }
}); });
} }
@@ -179,7 +208,7 @@ export default class AccountService {
numbers: true numbers: true
}); });
return prisma_client.account.update({ return prisma_client.account.update({
where: { id: account_id}, where: { id: account_id },
data: { join_password } data: { join_password }
}); });
} }
@@ -188,14 +217,17 @@ export default class AccountService {
// User must already be an ADMIN for the Account // User must already be an ADMIN for the Account
// Existing OWNER memberships are downgraded to ADMIN // Existing OWNER memberships are downgraded to ADMIN
// In future, some sort of Billing/Stripe tie in here e.g. changing email details on the Account, not sure. // In future, some sort of Billing/Stripe tie in here e.g. changing email details on the Account, not sure.
async claimOwnershipOfAccount(user_id: number, account_id: number): Promise<MembershipWithUser[]> { async claimOwnershipOfAccount(
user_id: number,
account_id: number
): Promise<MembershipWithUser[]> {
const membership = await prisma_client.membership.findUniqueOrThrow({ const membership = await prisma_client.membership.findUniqueOrThrow({
where: { where: {
user_id_account_id: { user_id_account_id: {
user_id: user_id, user_id: user_id,
account_id: account_id, account_id: account_id
} }
}, }
}); });
if (membership.access === ACCOUNT_ACCESS.OWNER) { if (membership.access === ACCOUNT_ACCESS.OWNER) {
@@ -207,20 +239,20 @@ export default class AccountService {
const existing_owner_memberships = await prisma_client.membership.findMany({ const existing_owner_memberships = await prisma_client.membership.findMany({
where: { where: {
account_id: account_id, account_id: account_id,
access: ACCOUNT_ACCESS.OWNER, access: ACCOUNT_ACCESS.OWNER
}, }
}); });
for(const existing_owner_membership of existing_owner_memberships) { for (const existing_owner_membership of existing_owner_memberships) {
await prisma_client.membership.update({ await prisma_client.membership.update({
where: { where: {
user_id_account_id: { user_id_account_id: {
user_id: existing_owner_membership.user_id, user_id: existing_owner_membership.user_id,
account_id: account_id, account_id: account_id
} }
}, },
data: { data: {
access: ACCOUNT_ACCESS.ADMIN, // Downgrade OWNER to ADMIN access: ACCOUNT_ACCESS.ADMIN // Downgrade OWNER to ADMIN
} }
}); });
} }
@@ -230,12 +262,12 @@ export default class AccountService {
where: { where: {
user_id_account_id: { user_id_account_id: {
user_id: user_id, user_id: user_id,
account_id: account_id, account_id: account_id
} }
}, },
data: { data: {
access: ACCOUNT_ACCESS.OWNER, access: ACCOUNT_ACCESS.OWNER
}, }
}); });
// return the full membership list because 2 members have changed. // return the full membership list because 2 members have changed.
@@ -246,33 +278,41 @@ export default class AccountService {
} }
// Upgrade access of a membership. Cannot use this method to upgrade to or downgrade from OWNER access // Upgrade access of a membership. Cannot use this method to upgrade to or downgrade from OWNER access
async changeUserAccessWithinAccount(user_id: number, account_id: number, access: ACCOUNT_ACCESS) { async changeUserAccessWithinAccount(
user_id: number,
account_id: number,
access: ACCOUNT_ACCESS
) {
if (access === ACCOUNT_ACCESS.OWNER) { if (access === ACCOUNT_ACCESS.OWNER) {
throw new Error('UNABLE TO UPDATE MEMBERSHIP: use claimOwnershipOfAccount method to change ownership'); throw new Error(
'UNABLE TO UPDATE MEMBERSHIP: use claimOwnershipOfAccount method to change ownership'
);
} }
const membership = await prisma_client.membership.findUniqueOrThrow({ const membership = await prisma_client.membership.findUniqueOrThrow({
where: { where: {
user_id_account_id: { user_id_account_id: {
user_id: user_id, user_id: user_id,
account_id: account_id, account_id: account_id
} }
}, }
}); });
if (membership.access === ACCOUNT_ACCESS.OWNER) { if (membership.access === ACCOUNT_ACCESS.OWNER) {
throw new Error('UNABLE TO UPDATE MEMBERSHIP: use claimOwnershipOfAccount method to change ownership'); throw new Error(
'UNABLE TO UPDATE MEMBERSHIP: use claimOwnershipOfAccount method to change ownership'
);
} }
return prisma_client.membership.update({ return prisma_client.membership.update({
where: { where: {
user_id_account_id: { user_id_account_id: {
user_id: user_id, user_id: user_id,
account_id: account_id, account_id: account_id
} }
}, },
data: { data: {
access: access, access: access
}, },
include: { include: {
account: true account: true
@@ -301,40 +341,48 @@ export default class AccountService {
} }
*/ */
async getAccountWithPeriodRollover (account_id: number){ async getAccountWithPeriodRollover(account_id: number) {
const account = await prisma_client.account.findFirstOrThrow({ const account = await prisma_client.account.findFirstOrThrow({
where: { id: account_id } where: { id: account_id }
}); });
if(account.plan_name === config.initialPlanName && account.current_period_ends < new Date()){ if (
account.plan_name === config.initialPlanName &&
account.current_period_ends < new Date()
) {
return await prisma_client.account.update({ return await prisma_client.account.update({
where: { id: account.id }, where: { id: account.id },
data: { data: {
current_period_ends: UtilService.addMonths(account.current_period_ends,1), current_period_ends: UtilService.addMonths(
account.current_period_ends,
1
),
// reset anything that is affected by the rollover // reset anything that is affected by the rollover
ai_gen_count: 0, ai_gen_count: 0
}, }
}); });
};
return account;
}
async checkAIGenCount(account_id: number){
const account = await this.getAccountWithPeriodRollover(account_id);
if(account.ai_gen_count >= account.ai_gen_max_pm){
throw new AccountLimitError('Monthly AI gen limit reached, no new AI Generations can be made');
} }
return account; return account;
} }
async incrementAIGenCount (account: any){ async checkAIGenCount(account_id: number) {
const account = await this.getAccountWithPeriodRollover(account_id);
if (account.ai_gen_count >= account.ai_gen_max_pm) {
throw new AccountLimitError(
'Monthly AI gen limit reached, no new AI Generations can be made'
);
}
return account;
}
async incrementAIGenCount(account: any) {
return await prisma_client.account.update({ return await prisma_client.account.update({
where: { id: account.id }, where: { id: account.id },
data: { data: {
ai_gen_count: account.ai_gen_count + 1, ai_gen_count: account.ai_gen_count + 1
} }
}); });
} }

View File

@@ -7,7 +7,9 @@ import generator from 'generate-password-ts';
const config = useRuntimeConfig(); const config = useRuntimeConfig();
export default class AuthService { export default class AuthService {
async getFullUserBySupabaseId(supabase_uid: string): Promise<FullDBUser | null> { async getFullUserBySupabaseId(
supabase_uid: string
): Promise<FullDBUser | null> {
return prisma_client.user.findFirst({ return prisma_client.user.findFirst({
where: { supabase_uid }, where: { supabase_uid },
...fullDBUser ...fullDBUser
@@ -21,14 +23,20 @@ export default class AuthService {
}); });
} }
async createUser( supabase_uid: string, display_name: string, email: string ): Promise<FullDBUser | null> { async createUser(
const trialPlan = await prisma_client.plan.findFirstOrThrow({ where: { name: config.initialPlanName}}); supabase_uid: string,
display_name: string,
email: string
): Promise<FullDBUser | null> {
const trialPlan = await prisma_client.plan.findFirstOrThrow({
where: { name: config.initialPlanName }
});
const join_password: string = generator.generate({ const join_password: string = generator.generate({
length: 10, length: 10,
numbers: true numbers: true
}); });
return prisma_client.user.create({ return prisma_client.user.create({
data:{ data: {
supabase_uid: supabase_uid, supabase_uid: supabase_uid,
display_name: display_name, display_name: display_name,
email: email, email: email,
@@ -37,13 +45,16 @@ export default class AuthService {
account: { account: {
create: { create: {
name: display_name, name: display_name,
current_period_ends: UtilService.addMonths(new Date(), config.initialPlanActiveMonths), current_period_ends: UtilService.addMonths(
new Date(),
config.initialPlanActiveMonths
),
plan_id: trialPlan.id, plan_id: trialPlan.id,
features: trialPlan.features, features: trialPlan.features,
max_notes: trialPlan.max_notes, max_notes: trialPlan.max_notes,
max_members: trialPlan.max_members, max_members: trialPlan.max_members,
plan_name: trialPlan.name, plan_name: trialPlan.name,
join_password: join_password, join_password: join_password
} }
}, },
access: ACCOUNT_ACCESS.OWNER access: ACCOUNT_ACCESS.OWNER

View File

@@ -1,6 +1,6 @@
export class AccountLimitError extends Error { export class AccountLimitError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
Object.setPrototypeOf(this, AccountLimitError.prototype); Object.setPrototypeOf(this, AccountLimitError.prototype);
} }
} }

View File

@@ -16,17 +16,19 @@ export default class NotesService {
return prisma_client.note.findMany({ where: { account_id } }); return prisma_client.note.findMany({ where: { account_id } });
} }
async createNote( account_id: number, note_text: string ) { async createNote(account_id: number, note_text: string) {
const account = await prisma_client.account.findFirstOrThrow({ const account = await prisma_client.account.findFirstOrThrow({
where: { id: account_id}, where: { id: account_id },
include: { notes: true} include: { notes: true }
}); });
if(account.notes.length>= account.max_notes){ if (account.notes.length >= account.max_notes) {
throw new AccountLimitError('Note Limit reached, no new notes can be added'); throw new AccountLimitError(
'Note Limit reached, no new notes can be added'
);
} }
return prisma_client.note.create({ data: { account_id, note_text }}); return prisma_client.note.create({ data: { account_id, note_text } });
} }
async updateNote(id: number, note_text: string) { async updateNote(id: number, note_text: string) {
@@ -44,14 +46,14 @@ export default class NotesService {
const prompt = ` const prompt = `
Write an interesting short note about ${userPrompt}. Write an interesting short note about ${userPrompt}.
Restrict the note to a single paragraph. Restrict the note to a single paragraph.
` `;
const completion = await openai.createCompletion({ const completion = await openai.createCompletion({
model: "text-davinci-003", model: 'text-davinci-003',
prompt, prompt,
temperature: 0.6, temperature: 0.6,
stop: "\n\n", stop: '\n\n',
max_tokens: 1000, max_tokens: 1000,
n: 1, n: 1
}); });
await accountService.incrementAIGenCount(account); await accountService.incrementAIGenCount(account);

View File

@@ -1,9 +1,9 @@
import { Configuration, OpenAIApi } from "openai"; import { Configuration, OpenAIApi } from 'openai';
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const configuration = new Configuration({ const configuration = new Configuration({
apiKey: config.openAIKey, apiKey: config.openAIKey
}); });
export const openai = new OpenAIApi(configuration); export const openai = new OpenAIApi(configuration);

View File

@@ -1,25 +1,39 @@
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
export const membershipWithAccount = Prisma.validator<Prisma.MembershipArgs>()({ export const membershipWithAccount = Prisma.validator<Prisma.MembershipArgs>()({
include: { account: true }, include: { account: true }
}) });
export type MembershipWithAccount = Prisma.MembershipGetPayload<typeof membershipWithAccount> export type MembershipWithAccount = Prisma.MembershipGetPayload<
typeof membershipWithAccount
>;
export const membershipWithUser = Prisma.validator<Prisma.MembershipArgs>()({ export const membershipWithUser = Prisma.validator<Prisma.MembershipArgs>()({
include: { user: true }, include: { user: true }
}) });
export type MembershipWithUser = Prisma.MembershipGetPayload<typeof membershipWithUser> export type MembershipWithUser = Prisma.MembershipGetPayload<
typeof membershipWithUser
>;
export const fullDBUser = Prisma.validator<Prisma.UserArgs>()({ export const fullDBUser = Prisma.validator<Prisma.UserArgs>()({
include: { memberships: {include: { include: {
account: true memberships: {
}}} include: {
account: true
}
}
}
}); });
export type FullDBUser = Prisma.UserGetPayload<typeof fullDBUser> //TODO - I wonder if this could be replaced by just user level info export type FullDBUser = Prisma.UserGetPayload<typeof fullDBUser>; //TODO - I wonder if this could be replaced by just user level info
export const accountWithMembers = Prisma.validator<Prisma.AccountArgs>()({ export const accountWithMembers = Prisma.validator<Prisma.AccountArgs>()({
include: { members: {include: { include: {
user: true members: {
}} } include: {
}) user: true
export type AccountWithMembers = Prisma.AccountGetPayload<typeof accountWithMembers> //TODO - I wonder if this could just be a list of full memberships }
}
}
});
export type AccountWithMembers = Prisma.AccountGetPayload<
typeof accountWithMembers
>; //TODO - I wonder if this could just be a list of full memberships

View File

@@ -9,14 +9,14 @@ export class UtilService {
} }
public static getErrorMessage(error: unknown) { public static getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message if (error instanceof Error) return error.message;
return String(error) return String(error);
} }
public static stringifySafely(obj: any) { public static stringifySafely(obj: any) {
let cache: any[] = []; let cache: any[] = [];
let str = JSON.stringify(obj, function(key, value) { let str = JSON.stringify(obj, function (key, value) {
if (typeof value === "object" && value !== null) { if (typeof value === 'object' && value !== null) {
if (cache.indexOf(value) !== -1) { if (cache.indexOf(value) !== -1) {
// Circular reference found, discard key // Circular reference found, discard key
return; return;

View File

@@ -1,7 +1,7 @@
export default defineNuxtRouteMiddleware(() => { export default defineNuxtRouteMiddleware(() => {
const user = useSupabaseUser() const user = useSupabaseUser();
if (!user.value) { if (!user.value) {
return navigateTo('/') return navigateTo('/');
} }
}) });

View File

@@ -7,19 +7,24 @@ export default defineNuxtConfig({
typescript: { typescript: {
shim: false shim: false
}, },
modules: ['@nuxtjs/supabase', '@pinia/nuxt', '@nuxtjs/tailwindcss', 'nuxt-icon'], modules: [
'@nuxtjs/supabase',
'@pinia/nuxt',
'@nuxtjs/tailwindcss',
'nuxt-icon'
],
imports: { imports: {
dirs: ['./stores'], dirs: ['./stores']
}, },
app:{ app: {
head: { head: {
htmlAttrs: { htmlAttrs: {
lang: 'en', lang: 'en'
}, },
title: 'SupaNuxt SaaS', title: 'SupaNuxt SaaS'
}, }
}, },
runtimeConfig:{ runtimeConfig: {
stripeSecretKey: process.env.STRIPE_SECRET_KEY, stripeSecretKey: process.env.STRIPE_SECRET_KEY,
stripeEndpointSecret: process.env.STRIPE_ENDPOINT_SECRET, stripeEndpointSecret: process.env.STRIPE_ENDPOINT_SECRET,
subscriptionGraceDays: 3, subscriptionGraceDays: 3,
@@ -28,7 +33,7 @@ export default defineNuxtConfig({
openAIKey: process.env.OPENAI_API_KEY, openAIKey: process.env.OPENAI_API_KEY,
public: { public: {
debugMode: true, debugMode: true,
siteRootUrl: process.env.URL || 'http://localhost:3000', // URL env variable is provided by netlify by default siteRootUrl: process.env.URL || 'http://localhost:3000' // URL env variable is provided by netlify by default
} }
} }
}) });

View File

@@ -3,10 +3,10 @@
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum'; import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
const accountStore = useAccountStore(); const accountStore = useAccountStore();
const { activeMembership, activeAccountMembers } = storeToRefs(accountStore) const { activeMembership, activeAccountMembers } = storeToRefs(accountStore);
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const newAccountName = ref(""); const newAccountName = ref('');
onMounted(async () => { onMounted(async () => {
await accountStore.init(); await accountStore.init();
@@ -14,8 +14,12 @@
}); });
function formatDate(date: Date | undefined) { function formatDate(date: Date | undefined) {
if (!date) { return ""; } if (!date) {
return new Intl.DateTimeFormat('default', { dateStyle: 'long' }).format(date); return '';
}
return new Intl.DateTimeFormat('default', { dateStyle: 'long' }).format(
date
);
} }
function joinURL() { function joinURL() {
@@ -25,7 +29,9 @@
<template> <template>
<div class="container mx-auto p-6"> <div class="container mx-auto p-6">
<div class="text-center mb-12"> <div class="text-center mb-12">
<h2 class="text-4xl font-extrabold tracking-tight text-gray-900 sm:text-5xl md:text-6xl mb-4">Account Information <h2
class="text-4xl font-extrabold tracking-tight text-gray-900 sm:text-5xl md:text-6xl mb-4">
Account Information
</h2> </h2>
</div> </div>
@@ -34,24 +40,39 @@
<span class="font-bold w-32">Account Name:</span> <span class="font-bold w-32">Account Name:</span>
<span>{{ activeMembership?.account.name }}</span> <span>{{ activeMembership?.account.name }}</span>
<template <template
v-if="activeMembership && (activeMembership.access === ACCOUNT_ACCESS.OWNER || activeMembership.access === ACCOUNT_ACCESS.ADMIN)"> v-if="
<input v-model="newAccountName" type="text" class="p-2 border border-gray-400 rounded w-1/3" activeMembership &&
placeholder="Enter new name"> (activeMembership.access === ACCOUNT_ACCESS.OWNER ||
<button @click.prevent="accountStore.changeAccountName(newAccountName)" activeMembership.access === ACCOUNT_ACCESS.ADMIN)
class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded">Change Name</button> ">
<input
v-model="newAccountName"
type="text"
class="p-2 border border-gray-400 rounded w-1/3"
placeholder="Enter new name" />
<button
@click.prevent="accountStore.changeAccountName(newAccountName)"
class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded">
Change Name
</button>
</template> </template>
</div> </div>
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<span class="font-bold w-32">Current Period Ends:</span> <span class="font-bold w-32">Current Period Ends:</span>
<span>{{ formatDate(activeMembership?.account.current_period_ends) }}</span> <span>{{
formatDate(activeMembership?.account.current_period_ends)
}}</span>
</div> </div>
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<span class="font-bold w-32">Permitted Features:</span> <span class="font-bold w-32">Permitted Features:</span>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span v-for="feature in activeMembership?.account.features" <span
class="bg-gray-200 text-gray-700 font-semibold py-1 px-2 rounded-full">{{ feature }}</span> v-for="feature in activeMembership?.account.features"
class="bg-gray-200 text-gray-700 font-semibold py-1 px-2 rounded-full">
{{ feature }}
</span>
</div> </div>
</div> </div>
@@ -62,7 +83,10 @@
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<span class="font-bold w-32">AI Gens for this Month/Max:</span> <span class="font-bold w-32">AI Gens for this Month/Max:</span>
<span>{{ activeMembership?.account.ai_gen_count }} / {{ activeMembership?.account.ai_gen_max_pm }}</span> <span>
{{ activeMembership?.account.ai_gen_count }} /
{{ activeMembership?.account.ai_gen_max_pm }}
</span>
</div> </div>
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
@@ -72,9 +96,15 @@
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<span class="font-bold w-32">Access Level:</span> <span class="font-bold w-32">Access Level:</span>
<span class="bg-green-500 text-white font-semibold py-1 px-2 rounded-full">{{ activeMembership?.access }}</span> <span
<button @click.prevent="accountStore.claimOwnershipOfAccount()" class="bg-green-500 text-white font-semibold py-1 px-2 rounded-full">
v-if="activeMembership && activeMembership.access === ACCOUNT_ACCESS.ADMIN " {{ activeMembership?.access }}
</span>
<button
@click.prevent="accountStore.claimOwnershipOfAccount()"
v-if="
activeMembership && activeMembership.access === ACCOUNT_ACCESS.ADMIN
"
class="bg-orange-500 hover:bg-orange-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue"> class="bg-orange-500 hover:bg-orange-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
Claim Ownership Claim Ownership
</button> </button>
@@ -88,47 +118,109 @@
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<span class="font-bold w-32">Join Link:</span> <span class="font-bold w-32">Join Link:</span>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<input disabled type="text" class="p-2 border border-gray-400 rounded w-full" :value="joinURL()"> <input
<button @click.prevent="accountStore.rotateJoinPassword()" disabled
v-if="activeMembership && (activeMembership.access === ACCOUNT_ACCESS.OWNER || activeMembership.access === ACCOUNT_ACCESS.ADMIN)" type="text"
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded">ReGen</button> class="p-2 border border-gray-400 rounded w-full"
:value="joinURL()" />
<button
@click.prevent="accountStore.rotateJoinPassword()"
v-if="
activeMembership &&
(activeMembership.access === ACCOUNT_ACCESS.OWNER ||
activeMembership.access === ACCOUNT_ACCESS.ADMIN)
"
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded">
ReGen
</button>
</div> </div>
</div> </div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<h2 class="text-lg font-bold">Members</h2> <h2 class="text-lg font-bold">Members</h2>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div v-for="accountMember in activeAccountMembers" class="flex gap-4 items-center"> <div
v-for="accountMember in activeAccountMembers"
class="flex gap-4 items-center">
<span>{{ accountMember.user.display_name }}</span> <span>{{ accountMember.user.display_name }}</span>
<span class="bg-green-500 text-white font-semibold py-1 px-2 rounded-full">{{ accountMember.access }}</span> <span
class="bg-green-500 text-white font-semibold py-1 px-2 rounded-full">
{{ accountMember.access }}
</span>
<span class="text-gray-500">({{ accountMember.user.email }})</span> <span class="text-gray-500">({{ accountMember.user.email }})</span>
<button @click.prevent="accountStore.acceptPendingMembership(accountMember.id)" <button
v-if="accountMember.pending && activeMembership && (activeMembership.access === ACCOUNT_ACCESS.OWNER || activeMembership.access === ACCOUNT_ACCESS.ADMIN)" @click.prevent="
accountStore.acceptPendingMembership(accountMember.id)
"
v-if="
accountMember.pending &&
activeMembership &&
(activeMembership.access === ACCOUNT_ACCESS.OWNER ||
activeMembership.access === ACCOUNT_ACCESS.ADMIN)
"
class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue"> class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
Accept Pending Membership Accept Pending Membership
</button> </button>
<button @click.prevent="accountStore.rejectPendingMembership(accountMember.id)" <button
v-if="accountMember.pending && activeMembership && (activeMembership.access === ACCOUNT_ACCESS.OWNER || activeMembership.access === ACCOUNT_ACCESS.ADMIN)" @click.prevent="
accountStore.rejectPendingMembership(accountMember.id)
"
v-if="
accountMember.pending &&
activeMembership &&
(activeMembership.access === ACCOUNT_ACCESS.OWNER ||
activeMembership.access === ACCOUNT_ACCESS.ADMIN)
"
class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue"> class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
Reject Pending Membership Reject Pending Membership
</button> </button>
<button @click.prevent="accountStore.changeUserAccessWithinAccount(accountMember.user.id, ACCOUNT_ACCESS.READ_WRITE)" <button
v-if="activeMembership && (activeMembership.access === ACCOUNT_ACCESS.OWNER || activeMembership.access === ACCOUNT_ACCESS.ADMIN) && accountMember.access === ACCOUNT_ACCESS.READ_ONLY && !accountMember.pending" @click.prevent="
accountStore.changeUserAccessWithinAccount(
accountMember.user.id,
ACCOUNT_ACCESS.READ_WRITE
)
"
v-if="
activeMembership &&
(activeMembership.access === ACCOUNT_ACCESS.OWNER ||
activeMembership.access === ACCOUNT_ACCESS.ADMIN) &&
accountMember.access === ACCOUNT_ACCESS.READ_ONLY &&
!accountMember.pending
"
class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue"> class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
Promote to Read/Write Promote to Read/Write
</button> </button>
<button @click.prevent="accountStore.changeUserAccessWithinAccount(accountMember.user.id, ACCOUNT_ACCESS.ADMIN)" <button
v-if="activeMembership && activeMembership.access === ACCOUNT_ACCESS.OWNER && accountMember.access === ACCOUNT_ACCESS.READ_WRITE && !accountMember.pending" @click.prevent="
accountStore.changeUserAccessWithinAccount(
accountMember.user.id,
ACCOUNT_ACCESS.ADMIN
)
"
v-if="
activeMembership &&
activeMembership.access === ACCOUNT_ACCESS.OWNER &&
accountMember.access === ACCOUNT_ACCESS.READ_WRITE &&
!accountMember.pending
"
class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue"> class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
Promote to Admin Promote to Admin
</button> </button>
<button @click.prevent="accountStore.deleteMembership(accountMember.id)" <button
v-if="activeMembership && activeMembership.access === ACCOUNT_ACCESS.OWNER && accountMember.access !== ACCOUNT_ACCESS.OWNER && !accountMember.pending" @click.prevent="accountStore.deleteMembership(accountMember.id)"
v-if="
activeMembership &&
activeMembership.access === ACCOUNT_ACCESS.OWNER &&
accountMember.access !== ACCOUNT_ACCESS.OWNER &&
!accountMember.pending
"
class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue"> class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
Remove Remove
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
</div></template> </template>

View File

@@ -1,8 +1,6 @@
<template> <template>
<div class="prose lg:prose-xl m-5"> <div class="prose lg:prose-xl m-5">
<p> <p>We are sorry that you canceled your transaction!</p>
We are sorry that you canceled your transaction!
</p>
<p> <p>
<NuxtLink to="/pricing">Pricing</NuxtLink> <NuxtLink to="/pricing">Pricing</NuxtLink>
</p> </p>

View File

@@ -3,22 +3,24 @@
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum'; import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
definePageMeta({ definePageMeta({
middleware: ['auth'], middleware: ['auth']
}); });
const accountStore = useAccountStore(); const accountStore = useAccountStore();
const { activeMembership } = storeToRefs(accountStore) const { activeMembership } = storeToRefs(accountStore);
const notesStore = useNotesStore(); const notesStore = useNotesStore();
const { notes } = storeToRefs(notesStore); // ensure the notes list is reactive const { notes } = storeToRefs(notesStore); // ensure the notes list is reactive
const newNoteText = ref('') const newNoteText = ref('');
async function addNote(){ async function addNote() {
await notesStore.createNote(newNoteText.value) await notesStore.createNote(newNoteText.value);
newNoteText.value = ''; newNoteText.value = '';
} }
async function genNote(){ async function genNote() {
const genNoteText = await notesStore.generateAINoteFromPrompt(newNoteText.value) const genNoteText = await notesStore.generateAINoteFromPrompt(
newNoteText.value
);
newNoteText.value = genNoteText; newNoteText.value = genNoteText;
} }
@@ -28,31 +30,59 @@
}); });
</script> </script>
<template> <template>
<div class="flex flex-col items-center max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:py-16 lg:px-8"> <div
class="flex flex-col items-center max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:py-16 lg:px-8">
<div class="text-center mb-12"> <div class="text-center mb-12">
<h2 class="text-4xl font-extrabold tracking-tight text-gray-900 sm:text-5xl md:text-6xl mb-4">Notes Dashboard</h2> <h2
class="text-4xl font-extrabold tracking-tight text-gray-900 sm:text-5xl md:text-6xl mb-4">
Notes Dashboard
</h2>
</div> </div>
<div v-if="activeMembership && (activeMembership.access === ACCOUNT_ACCESS.READ_WRITE || activeMembership.access === ACCOUNT_ACCESS.ADMIN || activeMembership.access === ACCOUNT_ACCESS.OWNER)" class="w-full max-w-md mx-auto mb-3"> <div
<textarea v-model="newNoteText" type="text" class="w-full rounded-l-md py-2 px-4 border-gray-400 border-2 focus:outline-none focus:border-blue-500" rows="5" placeholder="Add a note..."/> v-if="
activeMembership &&
(activeMembership.access === ACCOUNT_ACCESS.READ_WRITE ||
activeMembership.access === ACCOUNT_ACCESS.ADMIN ||
activeMembership.access === ACCOUNT_ACCESS.OWNER)
"
class="w-full max-w-md mx-auto mb-3">
<textarea
v-model="newNoteText"
type="text"
class="w-full rounded-l-md py-2 px-4 border-gray-400 border-2 focus:outline-none focus:border-blue-500"
rows="5"
placeholder="Add a note..." />
<div class="flex justify-evenly"> <div class="flex justify-evenly">
<button @click.prevent="addNote()" type="button" <button
@click.prevent="addNote()"
type="button"
class="px-4 py-2 text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-opacity-50"> class="px-4 py-2 text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-opacity-50">
Add Add
</button> </button>
<button v-if="activeMembership.account.features.includes('SPECIAL_FEATURE')" @click.prevent="genNote()" type="button" <button
v-if="activeMembership.account.features.includes('SPECIAL_FEATURE')"
@click.prevent="genNote()"
type="button"
class="px-4 py-2 text-white bg-purple-600 rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-600 focus:ring-opacity-50"> class="px-4 py-2 text-white bg-purple-600 rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-600 focus:ring-opacity-50">
Gen Gen
<Icon name="mdi:magic" class="h-6 w-6"/> <Icon name="mdi:magic" class="h-6 w-6" />
</button> </button>
</div> </div>
</div> </div>
<div class="w-full max-w-md"> <div class="w-full max-w-md">
<div v-for="note in notes" class="bg-white rounded-lg shadow-lg text-center px-6 py-8 md:mx-4 md:my-4"> <div
v-for="note in notes"
class="bg-white rounded-lg shadow-lg text-center px-6 py-8 md:mx-4 md:my-4">
<p class="text-gray-600 mb-4">{{ note.note_text }}</p> <p class="text-gray-600 mb-4">{{ note.note_text }}</p>
<button @click.prevent="notesStore.deleteNote(note.id)" <button
v-if="activeMembership && (activeMembership.access === ACCOUNT_ACCESS.ADMIN || activeMembership.access === ACCOUNT_ACCESS.OWNER)" @click.prevent="notesStore.deleteNote(note.id)"
v-if="
activeMembership &&
(activeMembership.access === ACCOUNT_ACCESS.ADMIN ||
activeMembership.access === ACCOUNT_ACCESS.OWNER)
"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue"> class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
Delete Delete
</button> </button>

View File

@@ -1,8 +1,6 @@
<template> <template>
<div class="prose lg:prose-xl m-5"> <div class="prose lg:prose-xl m-5">
<p> <p>We are sorry that you were unable to subscribe.</p>
We are sorry that you were unable to subscribe.
</p>
<p> <p>
<NuxtLink to="/pricing">Pricing</NuxtLink> <NuxtLink to="/pricing">Pricing</NuxtLink>
</p> </p>

View File

@@ -3,23 +3,30 @@
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const notifyStore = useNotifyStore(); const notifyStore = useNotifyStore();
const loading = ref(false) const loading = ref(false);
const email = ref('') const email = ref('');
const sendResetPasswordLink = async () => { const sendResetPasswordLink = async () => {
try { try {
loading.value = true loading.value = true;
const { data, error } = await supabase.auth.resetPasswordForEmail(email.value, { const { data, error } = await supabase.auth.resetPasswordForEmail(
redirectTo: `${config.public.siteRootUrl}/resetpassword`, email.value,
}) {
if (error) throw error redirectTo: `${config.public.siteRootUrl}/resetpassword`
else notifyStore.notify("Password Reset link sent, check your email.", NotificationType.Success); }
);
if (error) throw error;
else
notifyStore.notify(
'Password Reset link sent, check your email.',
NotificationType.Success
);
} catch (error) { } catch (error) {
notifyStore.notify(error, NotificationType.Error); notifyStore.notify(error, NotificationType.Error);
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
</script> </script>
<template> <template>
<div class="flex flex-col items-center justify-center h-screen bg-gray-100"> <div class="flex flex-col items-center justify-center h-screen bg-gray-100">
@@ -28,11 +35,20 @@
<form @submit.prevent="sendResetPasswordLink" class="space-y-4"> <form @submit.prevent="sendResetPasswordLink" class="space-y-4">
<div> <div>
<label for="email" class="block mb-2 font-bold">Email</label> <label for="email" class="block mb-2 font-bold">Email</label>
<input v-model="email" id="email" type="email" class="w-full p-2 border border-gray-400 rounded-md" <input
placeholder="Enter your email" required> v-model="email"
id="email"
type="email"
class="w-full p-2 border border-gray-400 rounded-md"
placeholder="Enter your email"
required />
</div> </div>
<button :disabled="loading || email === ''" type="submit" <button
class="w-full py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700">Send Reset Password Link</button> :disabled="loading || email === ''"
type="submit"
class="w-full py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Send Reset Password Link
</button>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
const user = useSupabaseUser() const user = useSupabaseUser();
watchEffect(() => { watchEffect(() => {
if (user.value) { if (user.value) {
navigateTo('/dashboard', {replace: true}) navigateTo('/dashboard', { replace: true });
} }
}) });
</script> </script>
<template> <template>
<div class="container mx-auto m-5"> <div class="container mx-auto m-5">
@@ -13,57 +13,72 @@ const user = useSupabaseUser()
<div class="container mx-auto"> <div class="container mx-auto">
<div class="grid grid-cols-1 md:grid-cols-2 gap-16"> <div class="grid grid-cols-1 md:grid-cols-2 gap-16">
<div class="m-5"> <div class="m-5">
<h1 class="text-5xl font-bold mb-4"> <h1 class="text-5xl font-bold mb-4">Build Your Next SaaS Faster</h1>
Build Your Next SaaS Faster
</h1>
<p class="text-gray-700 text-lg mb-8"> <p class="text-gray-700 text-lg mb-8">
With SupaNuxt SaaS, you can easily get started building your With SupaNuxt SaaS, you can easily get started building your next
next web application. Our pre-configured tech stack and web application. Our pre-configured tech stack and industry
industry leading features make it easy to get up and running in no time. Look! this guy is working so fast, leading features make it easy to get up and running in no time.
his hands are just a blur.. you could be this fast. Look! this guy is working so fast, his hands are just a blur.. you
could be this fast.
</p> </p>
<NuxtLink to="/signup" class="inline-block py-3 px-6 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Get Started</NuxtLink> <NuxtLink
to="/signup"
class="inline-block py-3 px-6 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>Get Started</NuxtLink
>
</div> </div>
<div> <div>
<img src="~/assets/images/supanuxt_logo_200.png" alt="SupaNuxt SaaS Logo"/> <img
src="~/assets/images/supanuxt_logo_200.png"
alt="SupaNuxt SaaS Logo" />
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="py-12"> <section class="py-12">
<div class="container px-4 mx-auto"> <div class="container px-4 mx-auto">
<div class="flex flex-col md:flex-row items-center justify-center md:justify-between mb-8"> <div
class="flex flex-col md:flex-row items-center justify-center md:justify-between mb-8">
<h2 class="text-3xl font-bold mb-4 md:mb-0">Tech Stack</h2> <h2 class="text-3xl font-bold mb-4 md:mb-0">Tech Stack</h2>
</div> </div>
<div class="flex flex-col md:flex-row items-center mb-16"> <div class="flex flex-col md:flex-row items-center mb-16">
<div class="md:w-full"> <div class="md:w-full">
<ul class="grid grid-cols-3 gap-10 list-none"> <ul class="grid grid-cols-3 gap-10 list-none">
<li> <li>
<Icon name="skill-icons:nuxtjs-dark" class="h-12 w-12 mb-2" /> <Icon name="skill-icons:nuxtjs-dark" class="h-12 w-12 mb-2" />
<h3 class="text-xl font-medium text-gray-900">Nuxt 3</h3> <h3 class="text-xl font-medium text-gray-900">Nuxt 3</h3>
<p class="mt-2 text-base text-gray-500">The Progressive Vue.js Framework</p> <p class="mt-2 text-base text-gray-500">
The Progressive Vue.js Framework
</p>
</li> </li>
<li> <li>
<Icon name="skill-icons:supabase-dark" class="h-12 w-12 mb-2" /> <Icon name="skill-icons:supabase-dark" class="h-12 w-12 mb-2" />
<h3 class="text-xl font-medium text-gray-900">Supabase</h3> <h3 class="text-xl font-medium text-gray-900">Supabase</h3>
<p class="mt-2 text-base text-gray-500">Auth including OAuth + Postgresql instance</p> <p class="mt-2 text-base text-gray-500">
Auth including OAuth + Postgresql instance
</p>
</li> </li>
<li> <li>
<Icon name="skill-icons:postgresql-dark" class="h-12 w-12 mb-2" /> <Icon
name="skill-icons:postgresql-dark"
class="h-12 w-12 mb-2" />
<h3 class="text-xl font-medium text-gray-900">PostgreSQL</h3> <h3 class="text-xl font-medium text-gray-900">PostgreSQL</h3>
<p class="mt-2 text-base text-gray-500">Relational Database</p> <p class="mt-2 text-base text-gray-500">Relational Database</p>
</li> </li>
<li> <li>
<Icon name="logos:prisma" class="h-12 w-12 mb-2" /> <Icon name="logos:prisma" class="h-12 w-12 mb-2" />
<h3 class="text-xl font-medium text-gray-900">Prisma</h3> <h3 class="text-xl font-medium text-gray-900">Prisma</h3>
<p class="mt-2 text-base text-gray-500">Schema management + Strongly typed client</p> <p class="mt-2 text-base text-gray-500">
Schema management + Strongly typed client
</p>
</li> </li>
<li> <li>
<Icon name="simple-icons:trpc" class="h-12 w-12 mb-2" /> <Icon name="simple-icons:trpc" class="h-12 w-12 mb-2" />
<h3 class="text-xl font-medium text-gray-900">TRPC</h3> <h3 class="text-xl font-medium text-gray-900">TRPC</h3>
<p class="mt-2 text-base text-gray-500">Server/Client communication with Strong types, SSR compatible</p> <p class="mt-2 text-base text-gray-500">
Server/Client communication with Strong types, SSR compatible
</p>
</li> </li>
<li> <li>
<Icon name="skill-icons:vuejs-dark" class="h-12 w-12 mb-2" /> <Icon name="skill-icons:vuejs-dark" class="h-12 w-12 mb-2" />
@@ -73,104 +88,148 @@ const user = useSupabaseUser()
<li> <li>
<Icon name="logos:stripe" class="h-12 w-12 mb-2" /> <Icon name="logos:stripe" class="h-12 w-12 mb-2" />
<h3 class="text-xl font-medium text-gray-900">Stripe</h3> <h3 class="text-xl font-medium text-gray-900">Stripe</h3>
<p class="mt-2 text-base text-gray-500">Payments including Webhook integration</p> <p class="mt-2 text-base text-gray-500">
Payments including Webhook integration
</p>
</li> </li>
<li> <li>
<Icon name="skill-icons:tailwindcss-dark" class="h-12 w-12 mb-2" /> <Icon
name="skill-icons:tailwindcss-dark"
class="h-12 w-12 mb-2" />
<h3 class="text-xl font-medium text-gray-900">Tailwind</h3> <h3 class="text-xl font-medium text-gray-900">Tailwind</h3>
<p class="mt-2 text-base text-gray-500">A utility-first CSS framework</p> <p class="mt-2 text-base text-gray-500">
A utility-first CSS framework
</p>
</li> </li>
<li> <li>
<Icon name="skill-icons:vuejs-dark" class="h-12 w-12 mb-2" /> <Icon name="skill-icons:vuejs-dark" class="h-12 w-12 mb-2" />
<h3 class="text-xl font-medium text-gray-900">Vue.js</h3> <h3 class="text-xl font-medium text-gray-900">Vue.js</h3>
<p class="mt-2 text-base text-gray-500">The Progressive JavaScript Framework</p> <p class="mt-2 text-base text-gray-500">
The Progressive JavaScript Framework
</p>
</li> </li>
<li> <li>
<Icon name="logos:openai-icon" class="h-12 w-12 mb-2" /> <Icon name="logos:openai-icon" class="h-12 w-12 mb-2" />
<h3 class="text-xl font-medium text-gray-900">OpenAI</h3> <h3 class="text-xl font-medium text-gray-900">OpenAI</h3>
<p class="mt-2 text-base text-gray-500">AI Completions including Note generation from prompt</p> <p class="mt-2 text-base text-gray-500">
AI Completions including Note generation from prompt
</p>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
<div class="flex flex-col md:flex-row items-center justify-center md:justify-between mb-8"> <div
class="flex flex-col md:flex-row items-center justify-center md:justify-between mb-8">
<h2 class="text-3xl font-bold mb-4 md:mb-0">Features</h2> <h2 class="text-3xl font-bold mb-4 md:mb-0">Features</h2>
</div> </div>
<!-- User Management (text left) --> <!-- User Management (text left) -->
<div class="flex flex-col md:flex-row-reverse items-center mb-16"> <div class="flex flex-col md:flex-row-reverse items-center mb-16">
<div class="md:w-1/2 md:ml-8 mb-8 md:mb-0"> <div class="md:w-1/2 md:ml-8 mb-8 md:mb-0">
<img src="~/assets/images/landing_user_management.jpeg" alt="User Management" <img
class="w-full rounded-lg shadow-lg mb-4 md:mb-0 md:ml-4"> src="~/assets/images/landing_user_management.jpeg"
alt="User Management"
class="w-full rounded-lg shadow-lg mb-4 md:mb-0 md:ml-4" />
</div> </div>
<div class="md:w-1/2"> <div class="md:w-1/2">
<h3 class="text-xl font-bold mb-4">User Management</h3> <h3 class="text-xl font-bold mb-4">User Management</h3>
<p class="mb-4">SupaNuxt SaaS includes robust user management features, including <p class="mb-4">
authentication with social login (oauth) or email/password, management of user roles and permissions, and SupaNuxt SaaS includes robust user management features, including
multi-user/team accounts that permit multiple users to share plan features including a team administration authentication with social login (oauth) or email/password,
facility and user roles within team. This is a great feature for businesses or community groups who want to management of user roles and permissions, and multi-user/team
share the cost of the plan.</p> accounts that permit multiple users to share plan features
including a team administration facility and user roles within
team. This is a great feature for businesses or community groups
who want to share the cost of the plan.
</p>
</div> </div>
</div> </div>
<!-- DB Schema (text right)--> <!-- DB Schema (text right)-->
<div class="flex flex-col md:flex-row items-center mb-16"> <div class="flex flex-col md:flex-row items-center mb-16">
<div class="md:w-1/2 md:mr-8 mb-8 md:mb-0"> <div class="md:w-1/2 md:mr-8 mb-8 md:mb-0">
<img src="~/assets/images/landing_db_schema_management.jpeg" alt="DB Schema Management" <img
class="w-full rounded-lg shadow-lg mb-4 md:mb-0 md:mr-4"> src="~/assets/images/landing_db_schema_management.jpeg"
alt="DB Schema Management"
class="w-full rounded-lg shadow-lg mb-4 md:mb-0 md:mr-4" />
</div> </div>
<div class="md:w-1/2"> <div class="md:w-1/2">
<h3 class="text-xl font-bold mb-4">DB Schema Management</h3> <h3 class="text-xl font-bold mb-4">DB Schema Management</h3>
<p class="mb-4">We use Prisma for schema management to make sure you can easily <p class="mb-4">
manage and keep track of your database schema. We also utilise Prisma based strong types which, with some help from TRPC, penetrate the entire stack all We use Prisma for schema management to make sure you can easily
the way to the web front end. This ensures that you can move fast with your feature development, alter schema and have those manage and keep track of your database schema. We also utilise
type changes instantly available and validated everywhere.</p> Prisma based strong types which, with some help from TRPC,
penetrate the entire stack all the way to the web front end. This
ensures that you can move fast with your feature development,
alter schema and have those type changes instantly available and
validated everywhere.
</p>
</div> </div>
</div> </div>
<!-- Config (text left) --> <!-- Config (text left) -->
<div class="flex flex-col md:flex-row-reverse items-center mb-16"> <div class="flex flex-col md:flex-row-reverse items-center mb-16">
<div class="md:w-1/2 md:ml-8 mb-8 md:mb-0"> <div class="md:w-1/2 md:ml-8 mb-8 md:mb-0">
<img src="~/assets/images/landing_config_environment.jpeg" alt="Config and Environment" <img
class="w-full rounded-lg shadow-lg mb-4 md:mb-0 md:ml-4"> src="~/assets/images/landing_config_environment.jpeg"
alt="Config and Environment"
class="w-full rounded-lg shadow-lg mb-4 md:mb-0 md:ml-4" />
</div> </div>
<div class="md:w-1/2"> <div class="md:w-1/2">
<h3 class="text-xl font-bold mb-4">Config and Environment</h3> <h3 class="text-xl font-bold mb-4">Config and Environment</h3>
<p class="mb-4">SupaNuxt SaaS includes an approach to config and environment <p class="mb-4">
management that enables customisation and management of api keys.</p> SupaNuxt SaaS includes an approach to config and environment
management that enables customisation and management of api keys.
</p>
</div> </div>
</div> </div>
<!-- State Management (text right)--> <!-- State Management (text right)-->
<div class="flex flex-col md:flex-row items-center mb-16"> <div class="flex flex-col md:flex-row items-center mb-16">
<div class="md:w-1/2 md:mr-8 mb-8 md:mb-0"> <div class="md:w-1/2 md:mr-8 mb-8 md:mb-0">
<img src="~/assets/images/landing_state_management.jpeg" alt="State Management" <img
class="w-full rounded-lg shadow-lg mb-4 md:mb-0 md:mr-4"> src="~/assets/images/landing_state_management.jpeg"
alt="State Management"
class="w-full rounded-lg shadow-lg mb-4 md:mb-0 md:mr-4" />
</div> </div>
<div class="md:w-1/2"> <div class="md:w-1/2">
<h3 class="text-xl font-bold mb-4">State Management</h3> <h3 class="text-xl font-bold mb-4">State Management</h3>
<p class="mb-4">SupaNuxt SaaS includes multi modal state management that supports both Single Page Application (SPA) <p class="mb-4">
pages such as dashboards and Server Side Rendered (SSR) style pages for public content that are crawlable by Search SupaNuxt SaaS includes multi modal state management that supports
engines like google and facilitate excellent Search Engine Optimisation (SEO).</p> both Single Page Application (SPA) pages such as dashboards and
Server Side Rendered (SSR) style pages for public content that are
crawlable by Search engines like google and facilitate excellent
Search Engine Optimisation (SEO).
</p>
</div> </div>
</div> </div>
<!-- Stripe (text left) --> <!-- Stripe (text left) -->
<div class="flex flex-col md:flex-row-reverse items-center mb-16"> <div class="flex flex-col md:flex-row-reverse items-center mb-16">
<div class="md:w-1/2 md:ml-8 mb-8 md:mb-0"> <div class="md:w-1/2 md:ml-8 mb-8 md:mb-0">
<img src="~/assets/images/landing_stripe_integration.jpeg" alt="Stripe Integration" <img
class="w-full rounded-lg shadow-lg mb-4 md:mb-0 md:ml-4"> src="~/assets/images/landing_stripe_integration.jpeg"
alt="Stripe Integration"
class="w-full rounded-lg shadow-lg mb-4 md:mb-0 md:ml-4" />
</div> </div>
<div class="md:w-1/2"> <div class="md:w-1/2">
<h3 class="text-xl font-bold mb-4">Stripe Integration</h3> <h3 class="text-xl font-bold mb-4">Stripe Integration</h3>
<p class="mb-4">SupaNuxt SaaS includes Stripe integration for subscription payments including <p class="mb-4">
Subscription based support for multi pricing and multiple plans.</p> SupaNuxt SaaS includes Stripe integration for subscription
payments including Subscription based support for multi pricing
and multiple plans.
</p>
</div> </div>
</div> </div>
<!-- Tailwind (text right)--> <!-- Tailwind (text right)-->
<div class="flex flex-col md:flex-row items-center mb-16"> <div class="flex flex-col md:flex-row items-center mb-16">
<div class="md:w-1/2 md:mr-8 mb-8 md:mb-0"> <div class="md:w-1/2 md:mr-8 mb-8 md:mb-0">
<img src="~/assets/images/landing_style_system.jpeg" alt="Style System" <img
class="w-full rounded-lg shadow-lg mb-4 md:mb-0 md:mr-4"> src="~/assets/images/landing_style_system.jpeg"
alt="Style System"
class="w-full rounded-lg shadow-lg mb-4 md:mb-0 md:mr-4" />
</div> </div>
<div class="md:w-1/2"> <div class="md:w-1/2">
<h3 class="text-xl font-bold mb-4">Style System</h3> <h3 class="text-xl font-bold mb-4">Style System</h3>
<p class="mb-4">SupaNuxt SaaS includes Tailwind integration for site styling including a themable UI components with daisyUI</p> <p class="mb-4">
SupaNuxt SaaS includes Tailwind integration for site styling
including a themable UI components with daisyUI
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -10,7 +10,9 @@
// this could probably be an elegant destructure here but I lost patience // this could probably be an elegant destructure here but I lost patience
let account: AccountWithMembers | undefined; let account: AccountWithMembers | undefined;
if (join_password) { if (join_password) {
const result = await $client.account.getAccountByJoinPassword.useQuery({ join_password }); const result = await $client.account.getAccountByJoinPassword.useQuery({
join_password
});
account = result.data.value?.account; account = result.data.value?.account;
} }
@@ -19,45 +21,51 @@
async function doJoin() { async function doJoin() {
if (account) { if (account) {
await accountStore.joinUserToAccountPending(account.id); await accountStore.joinUserToAccountPending(account.id);
navigateTo('/dashboard', {replace: true}) navigateTo('/dashboard', { replace: true });
} else { } else {
console.log(`Unable to Join`) console.log(`Unable to Join`);
} }
} }
</script> </script>
<template> <template>
<div class="py-10 px-4 sm:px-6 lg:px-8"> <div class="py-10 px-4 sm:px-6 lg:px-8">
<div class="max-w-md mx-auto"> <div class="max-w-md mx-auto">
<h2 class="text-3xl font-extrabold text-gray-900">Request to Join {{ account?.name }}</h2> <h2 class="text-3xl font-extrabold text-gray-900">
Request to Join {{ account?.name }}
</h2>
<template v-if="dbUser?.dbUser"> <template v-if="dbUser?.dbUser">
<p class="mt-2 text-sm text-gray-500"> <p class="mt-2 text-sm text-gray-500">
Click below to request to Join the team. Click below to request to Join the team. Your request to join will
Your request to join will remain as 'Pending' remain as 'Pending' untill the team administrators complete their
untill the team administrators complete their review.</p> review.
<p class="mt-2 text-sm text-gray-500"> </p>
<p class="mt-2 text-sm text-gray-500">
If your requeste is approved, you will become a member of the team and If your requeste is approved, you will become a member of the team and
will be able to switch to the team account at any time in order to share will be able to switch to the team account at any time in order to
the benefits of the team plan. share the benefits of the team plan.
</p> </p>
<div class="mt-6"> <div class="mt-6">
<button @click.prevent="doJoin()" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md <button
shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 @click.prevent="doJoin()"
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Join Join
</button> </button>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<p class="m-5 text-sm text-gray-500">Only signed in users can join a team. Please either Signup or Signin and then return to this page using the join link.</p> <p class="m-5 text-sm text-gray-500">
<button @click.prevent="navigateTo('/signup')" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md Only signed in users can join a team. Please either Signup or Signin
shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 and then return to this page using the join link.
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> </p>
<button
@click.prevent="navigateTo('/signup')"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Sign Up Sign Up
</button> </button>
<div class="m-10"></div> <div class="m-10"></div>
<button @click.prevent="navigateTo('/signin')" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md <button
shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 @click.prevent="navigateTo('/signin')"
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Sign In Sign In
</button> </button>
</template> </template>

View File

@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute(); const route = useRoute();
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { data: note } = await $client.notes.getById.useQuery({note_id: +route.params.note_id}); const { data: note } = await $client.notes.getById.useQuery({
note_id: +route.params.note_id
});
</script> </script>
<template> <template>
<div class="prose lg:prose-xl m-5"> <div class="prose lg:prose-xl m-5">
<h3>Note Detail {{ route.params.note_id }}</h3> <h3>Note Detail {{ route.params.note_id }}</h3>
<div class="prose lg:prose-xl m-5">{{ note?.note.note_text }}</div> <div class="prose lg:prose-xl m-5">{{ note?.note.note_text }}</div>
</div> </div>
</template> </template>

View File

@@ -2,7 +2,7 @@
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum'; import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
const accountStore = useAccountStore() const accountStore = useAccountStore();
const { activeMembership } = storeToRefs(accountStore); const { activeMembership } = storeToRefs(accountStore);
onMounted(async () => { onMounted(async () => {
@@ -10,39 +10,63 @@
}); });
</script> </script>
<template> <template>
<div class="flex flex-col items-center max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:py-16 lg:px-8"> <div
class="flex flex-col items-center max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:py-16 lg:px-8">
<div class="text-center mb-12"> <div class="text-center mb-12">
<h2 class="text-4xl font-extrabold tracking-tight text-gray-900 sm:text-5xl md:text-6xl mb-4">Flexible Pricing</h2> <h2
<p class="text-xl text-gray-500">SupaNuxt SaaS is completely free and open source but you can price your own SaaS like this</p> class="text-4xl font-extrabold tracking-tight text-gray-900 sm:text-5xl md:text-6xl mb-4">
Flexible Pricing
</h2>
<p class="text-xl text-gray-500">
SupaNuxt SaaS is completely free and open source but you can price your
own SaaS like this
</p>
</div> </div>
<div class="flex flex-col md:flex-row justify-center items-center"> <div class="flex flex-col md:flex-row justify-center items-center">
<!-- Free Plan --> <!-- Free Plan -->
<div class="bg-white rounded-lg shadow-lg text-center px-6 py-8 md:mx-4 md:my-4 md:flex-1"> <div
class="bg-white rounded-lg shadow-lg text-center px-6 py-8 md:mx-4 md:my-4 md:flex-1">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Free Plan</h3> <h3 class="text-2xl font-bold text-gray-900 mb-4">Free Plan</h3>
<p class="text-gray-600 mb-4">Single user, 10 notes</p> <p class="text-gray-600 mb-4">Single user, 10 notes</p>
<p class="text-3xl font-bold text-gray-900 mb-4">$0<span class="text-gray-600 text-lg">/mo</span></p> <p class="text-3xl font-bold text-gray-900 mb-4">
$0<span class="text-gray-600 text-lg">/mo</span>
</p>
<button <button
v-if="!activeMembership" v-if="!activeMembership"
@click.prevent="navigateTo('/signup')" @click.prevent="navigateTo('/signup')"
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue"> class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
Start for free Start for free
</button> </button>
</div> </div>
<!-- Personal Plan --> <!-- Personal Plan -->
<div class="bg-white rounded-lg shadow-lg text-center px-6 py-8 md:mx-4 md:my-4 md:flex-1"> <div
class="bg-white rounded-lg shadow-lg text-center px-6 py-8 md:mx-4 md:my-4 md:flex-1">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Personal Plan</h3> <h3 class="text-2xl font-bold text-gray-900 mb-4">Personal Plan</h3>
<p class="text-gray-600 mb-4">Single user, 100 notes</p> <p class="text-gray-600 mb-4">Single user, 100 notes</p>
<p class="text-3xl font-bold text-gray-900 mb-4">$15<span class="text-gray-600 text-lg">/mo</span></p> <p class="text-3xl font-bold text-gray-900 mb-4">
$15<span class="text-gray-600 text-lg">/mo</span>
</p>
<!-- logged in user gets a subscribe button--> <!-- logged in user gets a subscribe button-->
<form <form
action="/create-checkout-session" action="/create-checkout-session"
method="POST" method="POST"
v-if="activeMembership && (activeMembership.access === ACCOUNT_ACCESS.OWNER || activeMembership.access !== ACCOUNT_ACCESS.ADMIN) && (activeMembership?.account.plan_name !== 'Individual Plan')"> v-if="
<input type="hidden" name="price_id" value="price_1MpOiwJfLn4RhYiLqfy6U8ZR" /> activeMembership &&
<input type="hidden" name="account_id" :value="activeMembership?.account_id" /> (activeMembership.access === ACCOUNT_ACCESS.OWNER ||
activeMembership.access !== ACCOUNT_ACCESS.ADMIN) &&
activeMembership?.account.plan_name !== 'Individual Plan'
">
<input
type="hidden"
name="price_id"
value="price_1MpOiwJfLn4RhYiLqfy6U8ZR" />
<input
type="hidden"
name="account_id"
:value="activeMembership?.account_id" />
<button <button
type="submit" type="submit"
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue"> class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
@@ -51,26 +75,40 @@
</form> </form>
<!-- anon user gets a link to sign up --> <!-- anon user gets a link to sign up -->
<button <button
v-if="!activeMembership" v-if="!activeMembership"
@click.prevent="navigateTo('/signup')" @click.prevent="navigateTo('/signup')"
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue"> class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
Get started Get started
</button> </button>
</div> </div>
<!-- Team Plan --> <!-- Team Plan -->
<div class="bg-white rounded-lg shadow-lg text-center px-6 py-8 md:mx-4 md:my-4 md:flex-1"> <div
class="bg-white rounded-lg shadow-lg text-center px-6 py-8 md:mx-4 md:my-4 md:flex-1">
<h3 class="text-2xl font-bold text-gray-900 mb-4">Team Plan</h3> <h3 class="text-2xl font-bold text-gray-900 mb-4">Team Plan</h3>
<p class="text-gray-600 mb-4">10 users, 200 notes</p> <p class="text-gray-600 mb-4">10 users, 200 notes</p>
<p class="text-3xl font-bold text-gray-900 mb-4">$25<span class="text-gray-600 text-lg">/mo</span></p> <p class="text-3xl font-bold text-gray-900 mb-4">
$25<span class="text-gray-600 text-lg">/mo</span>
</p>
<!-- logged in user gets a subscribe button--> <!-- logged in user gets a subscribe button-->
<form <form
action="/create-checkout-session" action="/create-checkout-session"
method="POST" method="POST"
v-if="activeMembership && (activeMembership.access === ACCOUNT_ACCESS.OWNER || activeMembership.access !== ACCOUNT_ACCESS.ADMIN) && (activeMembership?.account.plan_name !== 'Team Plan')"> v-if="
<input type="hidden" name="price_id" value="price_1MpOjtJfLn4RhYiLsjzAso90" /> activeMembership &&
<input type="hidden" name="account_id" :value="activeMembership?.account_id" /> (activeMembership.access === ACCOUNT_ACCESS.OWNER ||
activeMembership.access !== ACCOUNT_ACCESS.ADMIN) &&
activeMembership?.account.plan_name !== 'Team Plan'
">
<input
type="hidden"
name="price_id"
value="price_1MpOjtJfLn4RhYiLsjzAso90" />
<input
type="hidden"
name="account_id"
:value="activeMembership?.account_id" />
<button <button
type="submit" type="submit"
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue"> class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
@@ -79,10 +117,10 @@
</form> </form>
<!-- anon user gets a link to sign up --> <!-- anon user gets a link to sign up -->
<button <button
v-if="!activeMembership" v-if="!activeMembership"
@click.prevent="navigateTo('/signup')" @click.prevent="navigateTo('/signup')"
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue"> class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-blue">
Get started Get started
</button> </button>
</div> </div>
</div> </div>

View File

@@ -2,53 +2,83 @@
<div class="prose lg:prose-xl m-5"> <div class="prose lg:prose-xl m-5">
<h1>Privacy Statement</h1> <h1>Privacy Statement</h1>
<p>Your privacy is important to us. This privacy statement explains what personal data we collect from you and how we <p>
use it. By using our website, you agree to the terms of this privacy statement.</p> Your privacy is important to us. This privacy statement explains what
personal data we collect from you and how we use it. By using our website,
you agree to the terms of this privacy statement.
</p>
<h2>Information we collect</h2> <h2>Information we collect</h2>
<p>We collect personal information that you voluntarily provide to us when you use our website, including your email <p>
and full name. We also collect non-personal information about your use of our website, such as your IP address, We collect personal information that you voluntarily provide to us when
browser type, and the pages you visit.</p> you use our website, including your email and full name. We also collect
non-personal information about your use of our website, such as your IP
address, browser type, and the pages you visit.
</p>
<p>In addition to the personal data that we collect directly from you, we also use a third-party authentication <p>
provider called Supabase to manage user authentication. When you use our website, Supabase may collect personal data In addition to the personal data that we collect directly from you, we
about you, such as your email address and authentication credentials. For more information about Supabase's data also use a third-party authentication provider called Supabase to manage
practices, please refer to their privacy policy at <a user authentication. When you use our website, Supabase may collect
href="https://supabase.com/privacy">https://supabase.com/privacy</a>.</p> personal data about you, such as your email address and authentication
credentials. For more information about Supabase's data practices, please
refer to their privacy policy at
<a href="https://supabase.com/privacy">https://supabase.com/privacy</a>.
</p>
<h2>How we use your information</h2> <h2>How we use your information</h2>
<p>We use your personal information to provide you with the products and services you request, to communicate with <p>
you, and to improve our website. We may also use your information for marketing purposes, but we will always give We use your personal information to provide you with the products and
you the option to opt-out of receiving marketing communications from us.</p> services you request, to communicate with you, and to improve our website.
We may also use your information for marketing purposes, but we will
always give you the option to opt-out of receiving marketing
communications from us.
</p>
<h2>Disclosure of your information</h2> <h2>Disclosure of your information</h2>
<p>We may disclose your personal information to third parties who provide services to us, such as website hosting, <p>
data analysis, and customer service. We may also disclose your information if we believe it is necessary to comply We may disclose your personal information to third parties who provide
with the law or to protect our rights or the rights of others.</p> services to us, such as website hosting, data analysis, and customer
service. We may also disclose your information if we believe it is
necessary to comply with the law or to protect our rights or the rights of
others.
</p>
<p>As mentioned above, we use a third-party authentication provider called Supabase to manage user authentication. <p>
Supabase may share your personal data with other third-party service providers that they use to provide their As mentioned above, we use a third-party authentication provider called
services, such as hosting and cloud storage providers. For more information about Supabase's data sharing practices, Supabase to manage user authentication. Supabase may share your personal
please refer to their privacy policy at <a href="https://supabase.com/privacy">https://supabase.com/privacy</a>.</p> data with other third-party service providers that they use to provide
their services, such as hosting and cloud storage providers. For more
information about Supabase's data sharing practices, please refer to their
privacy policy at
<a href="https://supabase.com/privacy">https://supabase.com/privacy</a>.
</p>
<h2>Security of your information</h2> <h2>Security of your information</h2>
<p>We take reasonable measures to protect your personal information from unauthorized access, use, or disclosure. <p>
However, no data transmission over the internet or electronic storage is completely secure, so we cannot guarantee We take reasonable measures to protect your personal information from
the absolute security of your information.</p> unauthorized access, use, or disclosure. However, no data transmission
over the internet or electronic storage is completely secure, so we cannot
guarantee the absolute security of your information.
</p>
<h2>Changes to this privacy statement</h2> <h2>Changes to this privacy statement</h2>
<p>We may update this privacy statement from time to time. Any changes will be posted on this page, so please check <p>
back periodically to review the most current version of the statement.</p> We may update this privacy statement from time to time. Any changes will
be posted on this page, so please check back periodically to review the
most current version of the statement.
</p>
<h2>Contact us</h2> <h2>Contact us</h2>
<p>If you have any questions or concerns about our privacy practices, please contact us at [insert contact <p>
information].</p> If you have any questions or concerns about our privacy practices, please
contact us at [insert contact information].
</p>
</div> </div>
</template> </template>

View File

@@ -3,27 +3,27 @@
const notifyStore = useNotifyStore(); const notifyStore = useNotifyStore();
const loading = ref(false) const loading = ref(false);
const password = ref('') const password = ref('');
const confirmPassword = ref('') const confirmPassword = ref('');
const changePassword = async () => { const changePassword = async () => {
try { try {
loading.value = true loading.value = true;
const { data, error } = await supabase.auth.updateUser({ const { data, error } = await supabase.auth.updateUser({
password: password.value password: password.value
}); });
if (error) throw error if (error) throw error;
else { else {
notifyStore.notify("password changed", NotificationType.Success); notifyStore.notify('password changed', NotificationType.Success);
navigateTo('/signin', {replace: true}); // navigate to signin because it is best practice although the auth session seems to be valid so it immediately redirects to dashboard navigateTo('/signin', { replace: true }); // navigate to signin because it is best practice although the auth session seems to be valid so it immediately redirects to dashboard
} }
} catch (error) { } catch (error) {
notifyStore.notify(error, NotificationType.Error); notifyStore.notify(error, NotificationType.Error);
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
</script> </script>
<template> <template>
<div class="flex flex-col items-center justify-center h-screen bg-gray-100"> <div class="flex flex-col items-center justify-center h-screen bg-gray-100">
@@ -31,17 +31,35 @@
<h1 class="text-3xl font-bold text-center">Forgot Pasword</h1> <h1 class="text-3xl font-bold text-center">Forgot Pasword</h1>
<form @submit.prevent="changePassword" class="space-y-4"> <form @submit.prevent="changePassword" class="space-y-4">
<div> <div>
<label for="password" class="block mb-2 font-bold">New Password</label> <label for="password" class="block mb-2 font-bold"
<input v-model="password" id="password" type="password" class="w-full p-2 border border-gray-400 rounded-md" >New Password</label
placeholder="Enter your new password" required> >
<input
v-model="password"
id="password"
type="password"
class="w-full p-2 border border-gray-400 rounded-md"
placeholder="Enter your new password"
required />
</div> </div>
<div> <div>
<label for="confirmPassword" class="block mb-2 font-bold">Confirm New Password</label> <label for="confirmPassword" class="block mb-2 font-bold"
<input v-model="confirmPassword" id="confirmPassword" type="password" class="w-full p-2 border border-gray-400 rounded-md" >Confirm New Password</label
placeholder="Confirm new password" required> >
<input
v-model="confirmPassword"
id="confirmPassword"
type="password"
class="w-full p-2 border border-gray-400 rounded-md"
placeholder="Confirm new password"
required />
</div> </div>
<button :disabled="loading || password === '' || (confirmPassword !== password)" type="submit" <button
class="w-full py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700">Change Password</button> :disabled="loading || password === '' || confirmPassword !== password"
type="submit"
class="w-full py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Change Password
</button>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -1,47 +1,53 @@
<script setup lang="ts"> <script setup lang="ts">
const user = useSupabaseUser() const user = useSupabaseUser();
const supabase = useSupabaseAuthClient(); const supabase = useSupabaseAuthClient();
const accountStore = useAccountStore() const accountStore = useAccountStore();
const notifyStore = useNotifyStore(); const notifyStore = useNotifyStore();
const loading = ref(false) const loading = ref(false);
const email = ref('') const email = ref('');
const password = ref('') const password = ref('');
const handleStandardSignin = async () => { const handleStandardSignin = async () => {
console.log(`handleStandardSignin email.value:${email.value}, password.value:${password.value}`); console.log(
`handleStandardSignin email.value:${email.value}, password.value:${password.value}`
);
try { try {
loading.value = true loading.value = true;
const { error } = await supabase.auth.signInWithPassword({ email: email.value, password: password.value }) const { error } = await supabase.auth.signInWithPassword({
if (error) throw error email: email.value,
password: password.value
});
if (error) throw error;
} catch (error) { } catch (error) {
notifyStore.notify(error, NotificationType.Error); notifyStore.notify(error, NotificationType.Error);
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
const handleGoogleSignin = async () => { const handleGoogleSignin = async () => {
console.log('handleGoogleSignin'); console.log('handleGoogleSignin');
try { try {
loading.value = true loading.value = true;
const { error } = await supabase.auth.signInWithOAuth({ provider: 'google' }) const { error } = await supabase.auth.signInWithOAuth({
if (error) throw error provider: 'google'
});
if (error) throw error;
} catch (error) { } catch (error) {
notifyStore.notify(error, NotificationType.Error); notifyStore.notify(error, NotificationType.Error);
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
watchEffect(async () => { watchEffect(async () => {
if (user.value) { if (user.value) {
await accountStore.init(); await accountStore.init();
navigateTo('/dashboard', {replace: true}) navigateTo('/dashboard', { replace: true });
} }
}) });
</script> </script>
<template> <template>
<div class="flex flex-col items-center justify-center h-screen bg-gray-100"> <div class="flex flex-col items-center justify-center h-screen bg-gray-100">
@@ -50,20 +56,40 @@
<form @submit.prevent="handleStandardSignin" class="space-y-4"> <form @submit.prevent="handleStandardSignin" class="space-y-4">
<div> <div>
<label for="email" class="block mb-2 font-bold">Email</label> <label for="email" class="block mb-2 font-bold">Email</label>
<input v-model="email" id="email" type="email" class="w-full p-2 border border-gray-400 rounded-md" <input
placeholder="Enter your email" required> v-model="email"
id="email"
type="email"
class="w-full p-2 border border-gray-400 rounded-md"
placeholder="Enter your email"
required />
</div> </div>
<div> <div>
<label for="password" class="block mb-2 font-bold">Password</label> <label for="password" class="block mb-2 font-bold">Password</label>
<input v-model="password" id="password" type="password" class="w-full p-2 border border-gray-400 rounded-md" <input
placeholder="Enter your password" required> v-model="password"
id="password"
type="password"
class="w-full p-2 border border-gray-400 rounded-md"
placeholder="Enter your password"
required />
</div> </div>
<NuxtLink id="forgotPasswordLink" to="/forgotpassword" class="text-right block">Forgot your password?</NuxtLink> <NuxtLink
<button :disabled="loading || password === ''" type="submit" id="forgotPasswordLink"
class="w-full py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700">Sign in</button> to="/forgotpassword"
class="text-right block"
>Forgot your password?</NuxtLink
>
<button
:disabled="loading || password === ''"
type="submit"
class="w-full py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Sign in
</button>
</form> </form>
<p class="text-center">or</p> <p class="text-center">or</p>
<button @click="handleGoogleSignin()" <button
@click="handleGoogleSignin()"
class="w-full py-2 text-white bg-red-600 rounded-md hover:bg-red-700"> class="w-full py-2 text-white bg-red-600 rounded-md hover:bg-red-700">
<span class="flex items-center justify-center space-x-2"> <span class="flex items-center justify-center space-x-2">
<Icon name="fa-brands:google" class="w-5 h-5" /> <Icon name="fa-brands:google" class="w-5 h-5" />

View File

@@ -1,37 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
const user = useSupabaseUser() const user = useSupabaseUser();
const supabase = useSupabaseAuthClient(); const supabase = useSupabaseAuthClient();
const notifyStore = useNotifyStore(); const notifyStore = useNotifyStore();
const loading = ref(false) const loading = ref(false);
const email = ref('') const email = ref('');
const password = ref('') const password = ref('');
const confirmPassword = ref('') const confirmPassword = ref('');
const signUpOk = ref(false) const signUpOk = ref(false);
const handleStandardSignup = async () => { const handleStandardSignup = async () => {
try { try {
loading.value = true loading.value = true;
const { data, error } = await supabase.auth.signUp({ email: email.value, password: password.value }) const { data, error } = await supabase.auth.signUp({
email: email.value,
password: password.value
});
if (error) { if (error) {
throw error throw error;
} } else {
else {
signUpOk.value = true; signUpOk.value = true;
} }
} catch (error) { } catch (error) {
notifyStore.notify(error, NotificationType.Error); notifyStore.notify(error, NotificationType.Error);
} finally { } finally {
loading.value = false loading.value = false;
} }
} };
watchEffect(() => { watchEffect(() => {
if (user.value) { if (user.value) {
navigateTo('/dashboard', { replace: true }) navigateTo('/dashboard', { replace: true });
} }
}) });
</script> </script>
<template> <template>
<div class="flex flex-col items-center justify-center h-screen bg-gray-100"> <div class="flex flex-col items-center justify-center h-screen bg-gray-100">
@@ -40,26 +42,51 @@
<form @submit.prevent="handleStandardSignup" class="space-y-4"> <form @submit.prevent="handleStandardSignup" class="space-y-4">
<div> <div>
<label for="email" class="block mb-2 font-bold">Email</label> <label for="email" class="block mb-2 font-bold">Email</label>
<input v-model="email" id="email" type="email" class="w-full p-2 border border-gray-400 rounded-md" <input
placeholder="Enter your email" required> v-model="email"
id="email"
type="email"
class="w-full p-2 border border-gray-400 rounded-md"
placeholder="Enter your email"
required />
</div> </div>
<div> <div>
<label for="password" class="block mb-2 font-bold">Password</label> <label for="password" class="block mb-2 font-bold">Password</label>
<input v-model="password" id="password" type="password" class="w-full p-2 border border-gray-400 rounded-md" <input
placeholder="Enter your password" required> v-model="password"
id="password"
type="password"
class="w-full p-2 border border-gray-400 rounded-md"
placeholder="Enter your password"
required />
</div> </div>
<div> <div>
<label for="confirmPassword" class="block mb-2 font-bold">Confirm Password</label> <label for="confirmPassword" class="block mb-2 font-bold"
<input v-model="confirmPassword" id="confirmPassword" type="password" >Confirm Password</label
class="w-full p-2 border border-gray-400 rounded-md" placeholder="Confirm your password" required> >
<input
v-model="confirmPassword"
id="confirmPassword"
type="password"
class="w-full p-2 border border-gray-400 rounded-md"
placeholder="Confirm your password"
required />
</div> </div>
<button :disabled="loading || password === '' || (confirmPassword !== password)" type="submit" <button
class="w-full py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700">Sign up</button> :disabled="loading || password === '' || confirmPassword !== password"
type="submit"
class="w-full py-2 text-white bg-indigo-600 rounded-md hover:bg-indigo-700">
Sign up
</button>
<p v-if="signUpOk" class="mt-4 text-lg text-center">You have successfully signed up. Please check your email for a link to confirm your email address and proceed.</p> <p v-if="signUpOk" class="mt-4 text-lg text-center">
You have successfully signed up. Please check your email for a link to
confirm your email address and proceed.
</p>
</form> </form>
<p class="text-center">or</p> <p class="text-center">or</p>
<button @click="supabase.auth.signInWithOAuth({ provider: 'google' })" <button
@click="supabase.auth.signInWithOAuth({ provider: 'google' })"
class="w-full py-2 text-white bg-red-600 rounded-md hover:bg-red-700"> class="w-full py-2 text-white bg-red-600 rounded-md hover:bg-red-700">
<span class="flex items-center justify-center space-x-2"> <span class="flex items-center justify-center space-x-2">
<Icon name="fa-brands:google" class="w-5 h-5" /> <Icon name="fa-brands:google" class="w-5 h-5" />
@@ -67,8 +94,9 @@
</span> </span>
</button> </button>
<p class="mt-4 text-xs text-center text-gray-500"> <p class="mt-4 text-xs text-center text-gray-500">
By proceeding, I agree to the <NuxtLink to="/privacy">Privacy Statement</NuxtLink> and <NuxtLink to="/terms">Terms By proceeding, I agree to the
of Service</NuxtLink> <NuxtLink to="/privacy">Privacy Statement</NuxtLink> and
<NuxtLink to="/terms">Terms of Service</NuxtLink>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,22 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import Stripe from 'stripe'; import Stripe from 'stripe';
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2022-11-15' }); const stripe = new Stripe(config.stripeSecretKey, {
const route = useRoute(); apiVersion: '2022-11-15'
let customer: Stripe.Response<Stripe.Customer | Stripe.DeletedCustomer> });
try{ const route = useRoute();
const session = await stripe.checkout.sessions.retrieve(route?.query?.session_id as string); let customer: Stripe.Response<Stripe.Customer | Stripe.DeletedCustomer>;
customer = await stripe.customers.retrieve(session?.customer as string); try {
} catch(e) { const session = await stripe.checkout.sessions.retrieve(
console.log(`Error ${e}`) route?.query?.session_id as string
} );
customer = await stripe.customers.retrieve(session?.customer as string);
} catch (e) {
console.log(`Error ${e}`);
}
</script> </script>
<template> <template>
<div class="prose lg:prose-xl m-5"> <div class="prose lg:prose-xl m-5">
<p> <p>
<span v-if="customer && !customer.deleted">We appreciate your business {{customer.name}}!</span> <span v-if="customer && !customer.deleted">
<span v-if="customer && customer.deleted">It appears your stripe customer information has been deleted!</span> We appreciate your business {{ customer.name }}!
</span>
<span v-if="customer && customer.deleted">
It appears your stripe customer information has been deleted!
</span>
</p> </p>
<p>Go to Your <NuxtLink to="/dashboard">Dashboard</NuxtLink></p> <p>Go to Your <NuxtLink to="/dashboard">Dashboard</NuxtLink></p>
</div> </div>

View File

@@ -2,57 +2,95 @@
<div class="prose lg:prose-xl m-5"> <div class="prose lg:prose-xl m-5">
<h1>Terms of Service</h1> <h1>Terms of Service</h1>
<p>These terms of service (the "Agreement") govern your use of our website and services (collectively, the <p>
"Service"). By using the Service, you agree to be bound by the terms of this Agreement. If you do not agree to These terms of service (the "Agreement") govern your use of our website
these terms, you may not use the Service.</p> and services (collectively, the "Service"). By using the Service, you
agree to be bound by the terms of this Agreement. If you do not agree to
these terms, you may not use the Service.
</p>
<h2>Use of the Service</h2> <h2>Use of the Service</h2>
<p>You may use the Service only for lawful purposes and in accordance with this Agreement. You agree not to use the <p>
Service:</p> You may use the Service only for lawful purposes and in accordance with
this Agreement. You agree not to use the Service:
</p>
<ul> <ul>
<li>In any way that violates any applicable federal, state, local, or international law or regulation</li> <li>
<li>To impersonate or attempt to impersonate us, our employees, another user, or any other person or entity</li> In any way that violates any applicable federal, state, local, or
<li>To engage in any other conduct that restricts or inhibits anyone's use or enjoyment of the Service, or which, international law or regulation
as determined by us, may harm us or users of the Service or expose them to liability</li> </li>
<li>
To impersonate or attempt to impersonate us, our employees, another
user, or any other person or entity
</li>
<li>
To engage in any other conduct that restricts or inhibits anyone's use
or enjoyment of the Service, or which, as determined by us, may harm us
or users of the Service or expose them to liability
</li>
</ul> </ul>
<h2>Intellectual Property</h2> <h2>Intellectual Property</h2>
<p>The Service and its entire contents, features, and functionality (including but not limited to all information, <p>
software, text, displays, images, video, and audio, and the design, selection, and arrangement thereof) are owned The Service and its entire contents, features, and functionality
by us, our licensors, or other providers of such material and are protected by United States and international (including but not limited to all information, software, text, displays,
copyright, trademark, patent, trade secret, and other intellectual property or proprietary rights laws.</p> images, video, and audio, and the design, selection, and arrangement
thereof) are owned by us, our licensors, or other providers of such
material and are protected by United States and international copyright,
trademark, patent, trade secret, and other intellectual property or
proprietary rights laws.
</p>
<p>You may not reproduce, distribute, modify, create derivative works of, publicly display, publicly perform, <p>
republish, download, store, or transmit any of the material on our website, except as follows:</p> You may not reproduce, distribute, modify, create derivative works of,
publicly display, publicly perform, republish, download, store, or
transmit any of the material on our website, except as follows:
</p>
<ul> <ul>
<li>Your computer may temporarily store copies of such materials in RAM incidental to your accessing and viewing <li>
those materials</li> Your computer may temporarily store copies of such materials in RAM
<li>You may store files that are automatically cached by your Web browser for display enhancement purposes</li> incidental to your accessing and viewing those materials
<li>You may print or download one copy of a reasonable number of pages of the website for your own personal, </li>
non-commercial use and not for further reproduction, publication, or distribution</li> <li>
You may store files that are automatically cached by your Web browser
for display enhancement purposes
</li>
<li>
You may print or download one copy of a reasonable number of pages of
the website for your own personal, non-commercial use and not for
further reproduction, publication, or distribution
</li>
</ul> </ul>
<h2>Disclaimer of Warranties</h2> <h2>Disclaimer of Warranties</h2>
<p>The Service is provided on an "as is" and "as available" basis, without any warranties of any kind, either <p>
express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, The Service is provided on an "as is" and "as available" basis, without
or non-infringement. We make no warranty that the Service will meet your requirements, be available on an any warranties of any kind, either express or implied, including but not
uninterrupted, secure, or error-free basis, or be free from viruses or other harmful components.</p> limited to warranties of merchantability, fitness for a particular
purpose, or non-infringement. We make no warranty that the Service will
meet your requirements, be available on an uninterrupted, secure, or
error-free basis, or be free from viruses or other harmful components.
</p>
<h2>Limitation of Liability</h2> <h2>Limitation of Liability</h2>
<p>In no event shall we be liable for any direct, indirect, incidental, special, or consequential damages arising <p>
out of or in any way connected with the use of the Service, whether based on contract, tort, strict liability, or In no event shall we be liable for any direct, indirect, incidental,
any other theory of liability.</p> special, or consequential damages arising out of or in any way connected
with the use of the Service, whether based on contract, tort, strict
liability, or any other theory of liability.
</p>
<h2>Indemnification</h2> <h2>Indemnification</h2>
<p>You agree to defend, indemnify, and hold us harmless from and against any claims, liabilities, damages, <p>
judgments, fines, costs, and expenses.</p> You agree to defend, indemnify, and hold us harmless from and against any
claims, liabilities, damages, judgments, fines, costs, and expenses.
</p>
</div> </div>
</template> </template>

View File

@@ -1,12 +1,12 @@
import "vanilla-cookieconsent/dist/cookieconsent.css"; import 'vanilla-cookieconsent/dist/cookieconsent.css';
import "vanilla-cookieconsent/src/cookieconsent.js"; import 'vanilla-cookieconsent/src/cookieconsent.js';
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin(nuxtApp => {
// @ts-ignore // @ts-ignore
const cookieConsent = window.initCookieConsent(); const cookieConsent = window.initCookieConsent();
cookieConsent.run({ cookieConsent.run({
current_lang: "en", current_lang: 'en',
autoclear_cookies: true, // default: false autoclear_cookies: true, // default: false
page_scripts: true, // default: false page_scripts: true, // default: false
@@ -41,91 +41,91 @@ export default defineNuxtPlugin((nuxtApp) => {
languages: { languages: {
en: { en: {
consent_modal: { consent_modal: {
title: "We use cookies!", title: 'We use cookies!',
description: description:
'Hi, this website uses essential cookies to ensure its proper operation and tracking cookies to understand how you interact with it. The latter will be set only after consent. <button type="button" data-cc="c-settings" class="cc-link">Let me choose</button>', 'Hi, this website uses essential cookies to ensure its proper operation and tracking cookies to understand how you interact with it. The latter will be set only after consent. <button type="button" data-cc="c-settings" class="cc-link">Let me choose</button>',
primary_btn: { primary_btn: {
text: "Accept all", text: 'Accept all',
role: "accept_all", // 'accept_selected' or 'accept_all' role: 'accept_all' // 'accept_selected' or 'accept_all'
}, },
secondary_btn: { secondary_btn: {
text: "Reject all", text: 'Reject all',
role: "accept_necessary", // 'settings' or 'accept_necessary' role: 'accept_necessary' // 'settings' or 'accept_necessary'
}, }
}, },
settings_modal: { settings_modal: {
title: "Cookie preferences", title: 'Cookie preferences',
save_settings_btn: "Save settings", save_settings_btn: 'Save settings',
accept_all_btn: "Accept all", accept_all_btn: 'Accept all',
reject_all_btn: "Reject all", reject_all_btn: 'Reject all',
close_btn_label: "Close", close_btn_label: 'Close',
// cookie_table_caption: 'Cookie list', // cookie_table_caption: 'Cookie list',
cookie_table_headers: [ cookie_table_headers: [
{ col1: "Name" }, { col1: 'Name' },
{ col2: "Domain" }, { col2: 'Domain' },
{ col3: "Expiration" }, { col3: 'Expiration' },
{ col4: "Description" }, { col4: 'Description' }
], ],
blocks: [ blocks: [
{ {
title: "Cookie usage 📢", title: 'Cookie usage 📢',
description: description:
'I use cookies to ensure the basic functionalities of the website and to enhance your online experience. You can choose for each category to opt-in/out whenever you want. For more details relative to cookies and other sensitive data, please read the full <a href="#" class="cc-link">privacy policy</a>.', 'I use cookies to ensure the basic functionalities of the website and to enhance your online experience. You can choose for each category to opt-in/out whenever you want. For more details relative to cookies and other sensitive data, please read the full <a href="#" class="cc-link">privacy policy</a>.'
}, },
{ {
title: "Strictly necessary cookies", title: 'Strictly necessary cookies',
description: description:
"These cookies are essential for the proper functioning of my website. Without these cookies, the website would not work properly", 'These cookies are essential for the proper functioning of my website. Without these cookies, the website would not work properly',
toggle: { toggle: {
value: "necessary", value: 'necessary',
enabled: true, enabled: true,
readonly: true, // cookie categories with readonly=true are all treated as "necessary cookies" readonly: true // cookie categories with readonly=true are all treated as "necessary cookies"
}, }
}, },
{ {
title: "Performance and Analytics cookies", title: 'Performance and Analytics cookies',
description: description:
"These cookies allow the website to remember the choices you have made in the past", 'These cookies allow the website to remember the choices you have made in the past',
toggle: { toggle: {
value: "analytics", // your cookie category value: 'analytics', // your cookie category
enabled: false, enabled: false,
readonly: false, readonly: false
}, },
cookie_table: [ cookie_table: [
// list of all expected cookies // list of all expected cookies
{ {
col1: "^_ga", // match all cookies starting with "_ga" col1: '^_ga', // match all cookies starting with "_ga"
col2: "google.com", col2: 'google.com',
col3: "2 years", col3: '2 years',
col4: "description ...", col4: 'description ...',
is_regex: true, is_regex: true
}, },
{ {
col1: "_gid", col1: '_gid',
col2: "google.com", col2: 'google.com',
col3: "1 day", col3: '1 day',
col4: "description ...", col4: 'description ...'
}, }
], ]
}, },
{ {
title: "Advertisement and Targeting cookies", title: 'Advertisement and Targeting cookies',
description: description:
"These cookies collect information about how you use the website, which pages you visited and which links you clicked on. All of the data is anonymized and cannot be used to identify you", 'These cookies collect information about how you use the website, which pages you visited and which links you clicked on. All of the data is anonymized and cannot be used to identify you',
toggle: { toggle: {
value: "targeting", value: 'targeting',
enabled: false, enabled: false,
readonly: false, readonly: false
}, }
}, },
{ {
title: "More information", title: 'More information',
description: description:
'For any queries in relation to our policy on cookies and your choices, please <a class="cc-link" href="#yourcontactpage">contact us</a>.', 'For any queries in relation to our policy on cookies and your choices, please <a class="cc-link" href="#yourcontactpage">contact us</a>.'
}, }
], ]
}, }
}, }
}, }
}); });
}); });

View File

@@ -1,6 +1,6 @@
import { createTRPCNuxtClient, httpBatchLink } from "trpc-nuxt/client"; import { createTRPCNuxtClient, httpBatchLink } from 'trpc-nuxt/client';
import type { AppRouter } from "~/server/trpc/routers/app.router"; import type { AppRouter } from '~/server/trpc/routers/app.router';
import superjson from "superjson"; import superjson from 'superjson';
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
/** /**
@@ -10,15 +10,15 @@ export default defineNuxtPlugin(() => {
const client = createTRPCNuxtClient<AppRouter>({ const client = createTRPCNuxtClient<AppRouter>({
links: [ links: [
httpBatchLink({ httpBatchLink({
url: "/api/trpc", url: '/api/trpc'
}), })
], ],
transformer: superjson, transformer: superjson
}); });
return { return {
provide: { provide: {
client, client
}, }
}; };
}); });

View File

@@ -1,15 +1,15 @@
// Workaround for prisma issue (https://github.com/prisma/prisma/issues/12504#issuecomment-1147356141) // Workaround for prisma issue (https://github.com/prisma/prisma/issues/12504#issuecomment-1147356141)
// Import original enum as type // Import original enum as type
import type { ACCOUNT_ACCESS as ACCOUNT_ACCESS_ORIGINAL } from '@prisma/client' import type { ACCOUNT_ACCESS as ACCOUNT_ACCESS_ORIGINAL } from '@prisma/client';
// Guarantee that the implementation corresponds to the original type // Guarantee that the implementation corresponds to the original type
export const ACCOUNT_ACCESS: { [k in ACCOUNT_ACCESS_ORIGINAL ]: k } = { export const ACCOUNT_ACCESS: { [k in ACCOUNT_ACCESS_ORIGINAL]: k } = {
READ_ONLY: 'READ_ONLY', READ_ONLY: 'READ_ONLY',
READ_WRITE: 'READ_WRITE', READ_WRITE: 'READ_WRITE',
ADMIN: 'ADMIN', ADMIN: 'ADMIN',
OWNER: 'OWNER' OWNER: 'OWNER'
} as const } as const;
// Re-exporting the original type with the original name // Re-exporting the original type with the original name
export type ACCOUNT_ACCESS = ACCOUNT_ACCESS_ORIGINAL export type ACCOUNT_ACCESS = ACCOUNT_ACCESS_ORIGINAL;

View File

@@ -1,5 +1,5 @@
import pkg from "@prisma/client"; import pkg from '@prisma/client';
const { PrismaClient } = pkg; const { PrismaClient } = pkg;
const prisma_client = new PrismaClient() const prisma_client = new PrismaClient();
export default prisma_client export default prisma_client;

View File

@@ -1,5 +1,5 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient() const prisma = new PrismaClient();
async function main() { async function main() {
const freeTrial = await prisma.plan.upsert({ const freeTrial = await prisma.plan.upsert({
where: { name: 'Free Trial' }, where: { name: 'Free Trial' },
@@ -9,8 +9,8 @@ async function main() {
features: ['ADD_NOTES', 'EDIT_NOTES', 'VIEW_NOTES'], features: ['ADD_NOTES', 'EDIT_NOTES', 'VIEW_NOTES'],
max_notes: 10, max_notes: 10,
max_members: 1, max_members: 1,
ai_gen_max_pm: 7, ai_gen_max_pm: 7
}, }
}); });
const individualPlan = await prisma.plan.upsert({ const individualPlan = await prisma.plan.upsert({
where: { name: 'Individual Plan' }, where: { name: 'Individual Plan' },
@@ -22,29 +22,35 @@ async function main() {
max_members: 1, max_members: 1,
ai_gen_max_pm: 50, ai_gen_max_pm: 50,
stripe_product_id: 'prod_NQR7vwUulvIeqW' stripe_product_id: 'prod_NQR7vwUulvIeqW'
}, }
}); });
const teamPlan = await prisma.plan.upsert({ const teamPlan = await prisma.plan.upsert({
where: { name: 'Team Plan' }, where: { name: 'Team Plan' },
update: {}, update: {},
create: { create: {
name: 'Team Plan', name: 'Team Plan',
features: ['ADD_NOTES', 'EDIT_NOTES', 'VIEW_NOTES', 'SPECIAL_FEATURE', 'SPECIAL_TEAM_FEATURE'], features: [
'ADD_NOTES',
'EDIT_NOTES',
'VIEW_NOTES',
'SPECIAL_FEATURE',
'SPECIAL_TEAM_FEATURE'
],
max_notes: 200, max_notes: 200,
max_members: 10, max_members: 10,
ai_gen_max_pm: 500, ai_gen_max_pm: 500,
stripe_product_id: 'prod_NQR8IkkdhqBwu2' stripe_product_id: 'prod_NQR8IkkdhqBwu2'
}, }
}); });
console.log({ freeTrial, individualPlan, teamPlan }) console.log({ freeTrial, individualPlan, teamPlan });
} }
main() main()
.then(async () => { .then(async () => {
await prisma.$disconnect() await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
}) })
.catch(async e => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

View File

@@ -4,10 +4,10 @@ import NotesService from '~/lib/services/notes.service';
// Example API Route with query params ... /api/note?note_id=41 // Example API Route with query params ... /api/note?note_id=41
export default defineProtectedEventHandler(async (event: H3Event) => { export default defineProtectedEventHandler(async (event: H3Event) => {
const queryParams = getQuery(event) const queryParams = getQuery(event);
let note_id: string = ''; let note_id: string = '';
if(queryParams.note_id){ if (queryParams.note_id) {
if (Array.isArray( queryParams.note_id)) { if (Array.isArray(queryParams.note_id)) {
note_id = queryParams.note_id[0]; note_id = queryParams.note_id[0];
} else { } else {
note_id = queryParams.note_id.toString(); note_id = queryParams.note_id.toString();
@@ -18,6 +18,6 @@ export default defineProtectedEventHandler(async (event: H3Event) => {
const note = await notesService.getNoteById(+note_id); const note = await notesService.getNoteById(+note_id);
return { return {
note, note
} };
}) });

View File

@@ -2,9 +2,9 @@
* This is the API-handler of your app that contains all your API routes. * This is the API-handler of your app that contains all your API routes.
* On a bigger app, you will probably want to split this file up into multiple files. * On a bigger app, you will probably want to split this file up into multiple files.
*/ */
import { createNuxtApiHandler } from "trpc-nuxt"; import { createNuxtApiHandler } from 'trpc-nuxt';
import { createContext } from "~~/server/trpc/context"; import { createContext } from '~~/server/trpc/context';
import { appRouter } from "~~/server/trpc/routers/app.router"; import { appRouter } from '~~/server/trpc/routers/app.router';
// export API handler // export API handler
export default createNuxtApiHandler({ export default createNuxtApiHandler({
@@ -12,5 +12,5 @@ export default createNuxtApiHandler({
createContext: createContext, createContext: createContext,
onError({ error }) { onError({ error }) {
console.error(error); console.error(error);
}, }
}); });

View File

@@ -1,4 +1,4 @@
import { EventHandler, EventHandlerRequest, H3Event, eventHandler } from "h3"; import { EventHandler, EventHandlerRequest, H3Event, eventHandler } from 'h3';
export const defineProtectedEventHandler = <T extends EventHandlerRequest>( export const defineProtectedEventHandler = <T extends EventHandlerRequest>(
handler: EventHandler<T> handler: EventHandler<T>
@@ -8,7 +8,7 @@ export const defineProtectedEventHandler = <T extends EventHandlerRequest>(
return eventHandler((event: H3Event) => { return eventHandler((event: H3Event) => {
const user = event.context.user; const user = event.context.user;
if (!user) { if (!user) {
throw createError({ statusCode: 401, statusMessage: "Unauthenticated" }); throw createError({ statusCode: 401, statusMessage: 'Unauthenticated' });
} }
return handler(event); return handler(event);
}); });

View File

@@ -1,22 +1,22 @@
import { defineEventHandler, parseCookies, setCookie, getCookie } from 'h3' import { defineEventHandler, parseCookies, setCookie, getCookie } from 'h3';
import { serverSupabaseUser } from "#supabase/server"; import { serverSupabaseUser } from '#supabase/server';
import AuthService from '~/lib/services/auth.service'; import AuthService from '~/lib/services/auth.service';
import { User } from '@supabase/supabase-js'; import { User } from '@supabase/supabase-js';
import { FullDBUser } from "~~/lib/services/service.types"; import { FullDBUser } from '~~/lib/services/service.types';
// Explicitly type our context by 'Merging' our custom types with the H3EventContext (https://stackoverflow.com/a/76349232/95242) // Explicitly type our context by 'Merging' our custom types with the H3EventContext (https://stackoverflow.com/a/76349232/95242)
declare module 'h3' { declare module 'h3' {
interface H3EventContext { interface H3EventContext {
user?: User; // the Supabase User user?: User; // the Supabase User
dbUser?: FullDBUser; // the corresponding Database User dbUser?: FullDBUser; // the corresponding Database User
activeAccountId?: number; // the account ID that is active for the user activeAccountId?: number; // the account ID that is active for the user
} }
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async event => {
const cookies = parseCookies(event) const cookies = parseCookies(event);
if(cookies && cookies['sb-access-token']){ if (cookies && cookies['sb-access-token']) {
const user = await serverSupabaseUser(event); const user = await serverSupabaseUser(event);
if (user) { if (user) {
event.context.user = user; event.context.user = user;
@@ -25,22 +25,38 @@ export default defineEventHandler(async (event) => {
let dbUser = await authService.getFullUserBySupabaseId(user.id); let dbUser = await authService.getFullUserBySupabaseId(user.id);
if (!dbUser && user) { if (!dbUser && user) {
dbUser = await authService.createUser(user.id, user.user_metadata.full_name?user.user_metadata.full_name:"no name supplied", user.email?user.email:"no@email.supplied" ); dbUser = await authService.createUser(
user.id,
user.user_metadata.full_name
? user.user_metadata.full_name
: 'no name supplied',
user.email ? user.email : 'no@email.supplied'
);
console.log(`\n Created DB User \n ${JSON.stringify(dbUser)}\n`); console.log(`\n Created DB User \n ${JSON.stringify(dbUser)}\n`);
} }
if(dbUser){ if (dbUser) {
event.context.dbUser = dbUser; event.context.dbUser = dbUser;
let activeAccountId; let activeAccountId;
const preferredAccountId = getCookie(event, 'preferred-active-account-id') const preferredAccountId = getCookie(
if(preferredAccountId && dbUser?.memberships.find(m => m.account_id === +preferredAccountId && !m.pending)){ event,
activeAccountId = +preferredAccountId 'preferred-active-account-id'
);
if (
preferredAccountId &&
dbUser?.memberships.find(
m => m.account_id === +preferredAccountId && !m.pending
)
) {
activeAccountId = +preferredAccountId;
} else { } else {
const defaultActive = dbUser.memberships[0].account_id.toString(); const defaultActive = dbUser.memberships[0].account_id.toString();
setCookie(event, 'preferred-active-account-id', defaultActive, {expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 10)}); setCookie(event, 'preferred-active-account-id', defaultActive, {
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 10)
});
activeAccountId = +defaultActive; activeAccountId = +defaultActive;
} }
if(activeAccountId){ if (activeAccountId) {
event.context.activeAccountId = activeAccountId; event.context.activeAccountId = activeAccountId;
} }
} }

View File

@@ -6,20 +6,31 @@ import { AccountWithMembers } from '~~/lib/services/service.types';
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2022-11-15' }); const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2022-11-15' });
export default defineEventHandler(async (event) => { export default defineEventHandler(async event => {
const body = await readBody(event) const body = await readBody(event);
let { price_id, account_id} = body; let { price_id, account_id } = body;
account_id = +account_id account_id = +account_id;
console.log(`session.post.ts recieved price_id:${price_id}, account_id:${account_id}`); console.log(
`session.post.ts recieved price_id:${price_id}, account_id:${account_id}`
);
const accountService = new AccountService(); const accountService = new AccountService();
const account: AccountWithMembers = await accountService.getAccountById(account_id); const account: AccountWithMembers = await accountService.getAccountById(
let customer_id: string account_id
if(!account.stripe_customer_id){ );
let customer_id: string;
if (!account.stripe_customer_id) {
// need to pre-emptively create a Stripe user for this account so we know who they are when the webhook comes back // need to pre-emptively create a Stripe user for this account so we know who they are when the webhook comes back
const owner = account.members.find(member => (member.access == ACCOUNT_ACCESS.OWNER)) const owner = account.members.find(
console.log(`Creating account with name ${account.name} and email ${owner?.user.email}`); member => member.access == ACCOUNT_ACCESS.OWNER
const customer = await stripe.customers.create({ name: account.name, email: owner?.user.email }); );
console.log(
`Creating account with name ${account.name} and email ${owner?.user.email}`
);
const customer = await stripe.customers.create({
name: account.name,
email: owner?.user.email
});
customer_id = customer.id; customer_id = customer.id;
accountService.updateAccountStipeCustomerId(account_id, customer.id); accountService.updateAccountStipeCustomerId(account_id, customer.id);
} else { } else {
@@ -31,8 +42,8 @@ export default defineEventHandler(async (event) => {
line_items: [ line_items: [
{ {
price: price_id, price: price_id,
quantity: 1, quantity: 1
}, }
], ],
// {CHECKOUT_SESSION_ID} is a string literal; do not change it! // {CHECKOUT_SESSION_ID} is a string literal; do not change it!
// the actual Session ID is returned in the query parameter when your customer // the actual Session ID is returned in the query parameter when your customer
@@ -42,11 +53,9 @@ export default defineEventHandler(async (event) => {
customer: customer_id customer: customer_id
}); });
if(session?.url){ if (session?.url) {
return sendRedirect(event, session.url, 303); return sendRedirect(event, session.url, 303);
} else { } else {
return sendRedirect(event, `${config.public.siteRootUrl}/fail`, 303); return sendRedirect(event, `${config.public.siteRootUrl}/fail`, 303);
} }
}); });

View File

@@ -4,45 +4,76 @@ import AccountService from '~~/lib/services/account.service';
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2022-11-15' }); const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2022-11-15' });
export default defineEventHandler(async (event) => { export default defineEventHandler(async event => {
const stripeSignature = getRequestHeader(event, 'stripe-signature'); const stripeSignature = getRequestHeader(event, 'stripe-signature');
if(!stripeSignature){ if (!stripeSignature) {
throw createError({ statusCode: 400, statusMessage: 'Webhook Error: No stripe signature in header' }); throw createError({
statusCode: 400,
statusMessage: 'Webhook Error: No stripe signature in header'
});
} }
const rawBody = await readRawBody(event) const rawBody = await readRawBody(event);
if(!rawBody){ if (!rawBody) {
throw createError({ statusCode: 400, statusMessage: 'Webhook Error: No body' }); throw createError({
statusCode: 400,
statusMessage: 'Webhook Error: No body'
});
} }
let stripeEvent: Stripe.Event; let stripeEvent: Stripe.Event;
try { try {
stripeEvent = stripe.webhooks.constructEvent(rawBody, stripeSignature, config.stripeEndpointSecret); stripeEvent = stripe.webhooks.constructEvent(
} rawBody,
catch (err) { stripeSignature,
config.stripeEndpointSecret
);
} catch (err) {
console.log(err); console.log(err);
throw createError({ statusCode: 400, statusMessage: `Error validating Webhook Event` }); throw createError({
statusCode: 400,
statusMessage: `Error validating Webhook Event`
});
} }
if(stripeEvent.type && stripeEvent.type.startsWith('customer.subscription')){ if (
stripeEvent.type &&
stripeEvent.type.startsWith('customer.subscription')
) {
console.log(`****** Web Hook Recieved (${stripeEvent.type}) ******`); console.log(`****** Web Hook Recieved (${stripeEvent.type}) ******`);
let subscription = stripeEvent.data.object as Stripe.Subscription; let subscription = stripeEvent.data.object as Stripe.Subscription;
if(subscription.status == 'active'){ if (subscription.status == 'active') {
const sub_item = subscription.items.data.find(item => item?.object && item?.object == 'subscription_item') const sub_item = subscription.items.data.find(
item => item?.object && item?.object == 'subscription_item'
);
const stripe_product_id = sub_item?.plan.product?.toString(); // TODO - is the product ever a product object and in that case should I check for deleted? const stripe_product_id = sub_item?.plan.product?.toString(); // TODO - is the product ever a product object and in that case should I check for deleted?
if(!stripe_product_id){ if (!stripe_product_id) {
throw createError({ statusCode: 400, statusMessage: `Error validating Webhook Event` }); throw createError({
statusCode: 400,
statusMessage: `Error validating Webhook Event`
});
} }
const accountService = new AccountService(); const accountService = new AccountService();
let current_period_ends: Date = new Date(subscription.current_period_end * 1000); let current_period_ends: Date = new Date(
current_period_ends.setDate(current_period_ends.getDate() + config.subscriptionGraceDays); subscription.current_period_end * 1000
);
current_period_ends.setDate(
current_period_ends.getDate() + config.subscriptionGraceDays
);
console.log(`updating stripe sub details subscription.current_period_end:${subscription.current_period_end}, subscription.id:${subscription.id}, stripe_product_id:${stripe_product_id}`); console.log(
accountService.updateStripeSubscriptionDetailsForAccount(subscription.customer.toString(), subscription.id, current_period_ends, stripe_product_id); `updating stripe sub details subscription.current_period_end:${subscription.current_period_end}, subscription.id:${subscription.id}, stripe_product_id:${stripe_product_id}`
);
accountService.updateStripeSubscriptionDetailsForAccount(
subscription.customer.toString(),
subscription.id,
current_period_ends,
stripe_product_id
);
} }
} }
return `handled ${stripeEvent.type}.`; return `handled ${stripeEvent.type}.`;

View File

@@ -1,13 +1,13 @@
import { inferAsyncReturnType } from '@trpc/server' import { inferAsyncReturnType } from '@trpc/server';
import { H3Event } from 'h3'; import { H3Event } from 'h3';
export async function createContext(event: H3Event){ export async function createContext(event: H3Event) {
return { return {
user: event.context.user, // the Supabase User user: event.context.user, // the Supabase User
dbUser: event.context.dbUser, // the corresponding Database User dbUser: event.context.dbUser, // the corresponding Database User
activeAccountId: event.context.activeAccountId, // the account ID that is active for the user activeAccountId: event.context.activeAccountId, // the account ID that is active for the user
event, // required to enable setCookie in accountRouter event // required to enable setCookie in accountRouter
} };
}; }
export type Context = inferAsyncReturnType<typeof createContext> export type Context = inferAsyncReturnType<typeof createContext>;

View File

@@ -1,5 +1,11 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { router, adminProcedure, publicProcedure, protectedProcedure, ownerProcedure } from '../trpc' import {
router,
adminProcedure,
publicProcedure,
protectedProcedure,
ownerProcedure
} from '../trpc';
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum'; import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
import { z } from 'zod'; import { z } from 'zod';
import AccountService from '~~/lib/services/account.service'; import AccountService from '~~/lib/services/account.service';
@@ -9,113 +15,161 @@ import { MembershipWithAccount } from '~~/lib/services/service.types';
Note on proliferation of Bang syntax... adminProcedure throws if either the ctx.dbUser or the ctx.activeAccountId is not available but the compiler can't figure that out so bang quiesces the null warning Note on proliferation of Bang syntax... adminProcedure throws if either the ctx.dbUser or the ctx.activeAccountId is not available but the compiler can't figure that out so bang quiesces the null warning
*/ */
export const accountRouter = router({ export const accountRouter = router({
getDBUser: publicProcedure getDBUser: publicProcedure.query(({ ctx }) => {
.query(({ ctx }) => { return {
return { dbUser: ctx.dbUser
dbUser: ctx.dbUser, };
} }),
}), getActiveAccountId: publicProcedure.query(({ ctx }) => {
getActiveAccountId: publicProcedure return {
.query(({ ctx }) => { activeAccountId: ctx.activeAccountId
return { };
activeAccountId: ctx.activeAccountId, }),
}
}),
changeActiveAccount: protectedProcedure changeActiveAccount: protectedProcedure
.input(z.object({ account_id: z.number() })) .input(z.object({ account_id: z.number() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const activeMembership = ctx.dbUser?.memberships.find(membership => membership.account_id == input.account_id); const activeMembership = ctx.dbUser?.memberships.find(
if(activeMembership?.pending){ membership => membership.account_id == input.account_id
throw new TRPCError({ code: 'BAD_REQUEST', message:`membership ${activeMembership?.id} is not active so cannot be switched to` }); );
if (activeMembership?.pending) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `membership ${activeMembership?.id} is not active so cannot be switched to`
});
} }
ctx.activeAccountId = input.account_id; ctx.activeAccountId = input.account_id;
setCookie(ctx.event, 'preferred-active-account-id', input.account_id.toString(), {expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 10)}); setCookie(
ctx.event,
'preferred-active-account-id',
input.account_id.toString(),
{ expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 10) }
);
}), }),
changeAccountName: adminProcedure changeAccountName: adminProcedure
.input(z.object({ new_name: z.string() })) .input(z.object({ new_name: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const accountService = new AccountService(); const accountService = new AccountService();
const account = await accountService.changeAccountName(ctx.activeAccountId!, input.new_name); const account = await accountService.changeAccountName(
ctx.activeAccountId!,
input.new_name
);
return { return {
account, account
} };
}),
rotateJoinPassword: adminProcedure
.mutation(async ({ ctx }) => {
const accountService = new AccountService();
const account = await accountService.rotateJoinPassword(ctx.activeAccountId!);
return {
account,
}
}), }),
rotateJoinPassword: adminProcedure.mutation(async ({ ctx }) => {
const accountService = new AccountService();
const account = await accountService.rotateJoinPassword(
ctx.activeAccountId!
);
return {
account
};
}),
getAccountByJoinPassword: publicProcedure getAccountByJoinPassword: publicProcedure
.input(z.object({ join_password: z.string() })) .input(z.object({ join_password: z.string() }))
.query(async ({ input }) => { .query(async ({ input }) => {
const accountService = new AccountService(); const accountService = new AccountService();
const account = await accountService.getAccountByJoinPassword(input.join_password); const account = await accountService.getAccountByJoinPassword(
input.join_password
);
return { return {
account, account
} };
}), }),
joinUserToAccountPending: publicProcedure // this uses a passed account id rather than using the active account because user is usually active on their personal or some other account when they attempt to join a new account joinUserToAccountPending: publicProcedure // this uses a passed account id rather than using the active account because user is usually active on their personal or some other account when they attempt to join a new account
.input(z.object({ account_id: z.number(), user_id: z.number() })) .input(z.object({ account_id: z.number(), user_id: z.number() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const accountService = new AccountService(); const accountService = new AccountService();
const membership: MembershipWithAccount = await accountService.joinUserToAccount(input.user_id, input.account_id, true); const membership: MembershipWithAccount =
await accountService.joinUserToAccount(
input.user_id,
input.account_id,
true
);
return { return {
membership, membership
} };
}), }),
acceptPendingMembership: adminProcedure acceptPendingMembership: adminProcedure
.input(z.object({ membership_id: z.number() })) .input(z.object({ membership_id: z.number() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const accountService = new AccountService(); const accountService = new AccountService();
const membership: MembershipWithAccount = await accountService.acceptPendingMembership(ctx.activeAccountId!, input.membership_id); const membership: MembershipWithAccount =
await accountService.acceptPendingMembership(
ctx.activeAccountId!,
input.membership_id
);
return { return {
membership, membership
} };
}), }),
rejectPendingMembership: adminProcedure rejectPendingMembership: adminProcedure
.input(z.object({ membership_id: z.number() })) .input(z.object({ membership_id: z.number() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const accountService = new AccountService(); const accountService = new AccountService();
const membership: MembershipWithAccount = await accountService.deleteMembership(ctx.activeAccountId!, input.membership_id); const membership: MembershipWithAccount =
await accountService.deleteMembership(
ctx.activeAccountId!,
input.membership_id
);
return { return {
membership, membership
} };
}), }),
deleteMembership: ownerProcedure deleteMembership: ownerProcedure
.input(z.object({ membership_id: z.number() })) .input(z.object({ membership_id: z.number() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const accountService = new AccountService(); const accountService = new AccountService();
const membership: MembershipWithAccount = await accountService.deleteMembership(ctx.activeAccountId!, input.membership_id); const membership: MembershipWithAccount =
await accountService.deleteMembership(
ctx.activeAccountId!,
input.membership_id
);
return { return {
membership, membership
} };
}), }),
changeUserAccessWithinAccount: adminProcedure changeUserAccessWithinAccount: adminProcedure
.input(z.object({ user_id: z.number(), access: z.enum([ACCOUNT_ACCESS.ADMIN, ACCOUNT_ACCESS.OWNER, ACCOUNT_ACCESS.READ_ONLY, ACCOUNT_ACCESS.READ_WRITE]) })) .input(
z.object({
user_id: z.number(),
access: z.enum([
ACCOUNT_ACCESS.ADMIN,
ACCOUNT_ACCESS.OWNER,
ACCOUNT_ACCESS.READ_ONLY,
ACCOUNT_ACCESS.READ_WRITE
])
})
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const accountService = new AccountService(); const accountService = new AccountService();
const membership = await accountService.changeUserAccessWithinAccount(input.user_id, ctx.activeAccountId!, input.access); const membership = await accountService.changeUserAccessWithinAccount(
input.user_id,
ctx.activeAccountId!,
input.access
);
return { return {
membership, membership
} };
}), }),
claimOwnershipOfAccount: adminProcedure claimOwnershipOfAccount: adminProcedure.mutation(async ({ ctx }) => {
.mutation(async ({ ctx }) => { const accountService = new AccountService();
const accountService = new AccountService(); const memberships = await accountService.claimOwnershipOfAccount(
const memberships = await accountService.claimOwnershipOfAccount(ctx.dbUser!.id, ctx.activeAccountId!); ctx.dbUser!.id,
return { ctx.activeAccountId!
memberships, );
} return {
}), memberships
getAccountMembers: adminProcedure };
.query(async ({ ctx }) => { }),
const accountService = new AccountService(); getAccountMembers: adminProcedure.query(async ({ ctx }) => {
const memberships = await accountService.getAccountMembers(ctx.activeAccountId!); const accountService = new AccountService();
return { const memberships = await accountService.getAccountMembers(
memberships, ctx.activeAccountId!
} );
}), return {
}) memberships
};
})
});

View File

@@ -1,12 +1,12 @@
import { router } from "~/server/trpc/trpc"; import { router } from '~/server/trpc/trpc';
import { notesRouter } from "./notes.router"; import { notesRouter } from './notes.router';
import { authRouter } from "./auth.router"; import { authRouter } from './auth.router';
import { accountRouter } from "./account.router"; import { accountRouter } from './account.router';
export const appRouter = router({ export const appRouter = router({
notes: notesRouter, notes: notesRouter,
auth: authRouter, auth: authRouter,
account: accountRouter, account: accountRouter
}); });
// export only the type definition of the API // export only the type definition of the API

View File

@@ -1,10 +1,9 @@
import { publicProcedure, router } from '../trpc' import { publicProcedure, router } from '../trpc';
export const authRouter = router({ export const authRouter = router({
getDBUser: publicProcedure getDBUser: publicProcedure.query(({ ctx }) => {
.query(({ ctx }) => { return {
return { dbUser: ctx.dbUser
dbUser: ctx.dbUser, };
} })
}), });
})

View File

@@ -1,50 +1,68 @@
import NotesService from '~~/lib/services/notes.service'; import NotesService from '~~/lib/services/notes.service';
import { accountHasSpecialFeature, adminProcedure, memberProcedure, publicProcedure, readWriteProcedure, router } from '../trpc'; import {
accountHasSpecialFeature,
adminProcedure,
memberProcedure,
publicProcedure,
readWriteProcedure,
router
} from '../trpc';
import { z } from 'zod'; import { z } from 'zod';
export const notesRouter = router({ export const notesRouter = router({
getForActiveAccount: memberProcedure getForActiveAccount: memberProcedure.query(async ({ ctx, input }) => {
.query(async ({ ctx, input }) => { const notesService = new NotesService();
const notesService = new NotesService(); const notes = ctx.activeAccountId
const notes = (ctx.activeAccountId)?await notesService.getNotesForAccountId(ctx.activeAccountId):[]; ? await notesService.getNotesForAccountId(ctx.activeAccountId)
return { : [];
notes, return {
} notes
}), };
}),
getById: publicProcedure getById: publicProcedure
.input(z.object({ note_id: z.number() })) .input(z.object({ note_id: z.number() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const notesService = new NotesService(); const notesService = new NotesService();
const note = await notesService.getNoteById(input.note_id); const note = await notesService.getNoteById(input.note_id);
return { return {
note, note
} };
}), }),
createNote: readWriteProcedure createNote: readWriteProcedure
.input(z.object({ note_text: z.string() })) .input(z.object({ note_text: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const notesService = new NotesService(); const notesService = new NotesService();
const note = (ctx.activeAccountId)?await notesService.createNote(ctx.activeAccountId, input.note_text):null; const note = ctx.activeAccountId
? await notesService.createNote(ctx.activeAccountId, input.note_text)
: null;
return { return {
note, note
} };
}), }),
deleteNote: adminProcedure deleteNote: adminProcedure
.input(z.object({ note_id: z.number() })) .input(z.object({ note_id: z.number() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const notesService = new NotesService(); const notesService = new NotesService();
const note = (ctx.activeAccountId)?await notesService.deleteNote(input.note_id):null; const note = ctx.activeAccountId
? await notesService.deleteNote(input.note_id)
: null;
return { return {
note, note
} };
}), }),
generateAINoteFromPrompt: readWriteProcedure.use(accountHasSpecialFeature) generateAINoteFromPrompt: readWriteProcedure
.use(accountHasSpecialFeature)
.input(z.object({ user_prompt: z.string() })) .input(z.object({ user_prompt: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const notesService = new NotesService(); const notesService = new NotesService();
const noteText = (ctx.activeAccountId)?await notesService.generateAINoteFromPrompt(input.user_prompt, ctx.activeAccountId):null; const noteText = ctx.activeAccountId
? await notesService.generateAINoteFromPrompt(
input.user_prompt,
ctx.activeAccountId
)
: null;
return { return {
noteText noteText
} };
}), })
}) });

View File

@@ -7,7 +7,7 @@
* @see https://trpc.io/docs/v10/router * @see https://trpc.io/docs/v10/router
* @see https://trpc.io/docs/v10/procedures * @see https://trpc.io/docs/v10/procedures
*/ */
import { initTRPC, TRPCError } from '@trpc/server' import { initTRPC, TRPCError } from '@trpc/server';
import { Context } from './context'; import { Context } from './context';
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum'; import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
import superjson from 'superjson'; import superjson from 'superjson';
@@ -15,7 +15,7 @@ import { AccountLimitError } from '~~/lib/services/errors';
const t = initTRPC.context<Context>().create({ const t = initTRPC.context<Context>().create({
transformer: superjson, transformer: superjson,
errorFormatter: (opts)=> { errorFormatter: opts => {
const { shape, error } = opts; const { shape, error } = opts;
if (!(error.cause instanceof AccountLimitError)) { if (!(error.cause instanceof AccountLimitError)) {
return shape; return shape;
@@ -26,10 +26,10 @@ const t = initTRPC.context<Context>().create({
...shape.data, ...shape.data,
httpStatus: 401, httpStatus: 401,
code: 'UNAUTHORIZED' code: 'UNAUTHORIZED'
}, }
}; };
} }
}) });
/** /**
* auth middlewares * auth middlewares
@@ -40,58 +40,97 @@ const isAuthed = t.middleware(({ next, ctx }) => {
} }
return next({ return next({
ctx: { ctx: {
user: ctx.user, user: ctx.user
}, }
}); });
}); });
const isMemberWithAccessesForActiveAccountId = (access: ACCOUNT_ACCESS[]) =>
t.middleware(({ next, ctx }) => {
if (!ctx.dbUser || !ctx.activeAccountId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'no user or active account information was found'
});
}
const activeMembership = ctx.dbUser.memberships.find(
membership => membership.account_id == ctx.activeAccountId
);
const isMemberWithAccessesForActiveAccountId = (access: ACCOUNT_ACCESS[]) => t.middleware(({ next, ctx }) => { console.log(
if (!ctx.dbUser || !ctx.activeAccountId) { `isMemberWithAccessesForActiveAccountId(${access}) activeMembership?.access:${activeMembership?.access}`
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'no user or active account information was found' }); );
}
const activeMembership = ctx.dbUser.memberships.find(membership => membership.account_id == ctx.activeAccountId);
console.log(`isMemberWithAccessesForActiveAccountId(${access}) activeMembership?.access:${activeMembership?.access}`); if (!activeMembership) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: `user is not a member of the active account`
});
}
if(!activeMembership) { if (activeMembership.pending) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`user is not a member of the active account` }); throw new TRPCError({
} code: 'UNAUTHORIZED',
message: `membership ${activeMembership?.id} is pending approval`
});
}
if(activeMembership.pending) { if (access.length > 0 && !access.includes(activeMembership.access)) {
throw new TRPCError({ code: 'UNAUTHORIZED', message:`membership ${activeMembership?.id} is pending approval` }); throw new TRPCError({
} code: 'UNAUTHORIZED',
message: `activeMembership ${activeMembership?.id} has insufficient access (${activeMembership?.access})`
});
}
if(access.length > 0 && !access.includes(activeMembership.access)) { return next({ ctx });
throw new TRPCError({ code: 'UNAUTHORIZED', message:`activeMembership ${activeMembership?.id} has insufficient access (${activeMembership?.access})` }); });
}
return next({ ctx }); export const isAccountWithFeature = (feature: string) =>
}); t.middleware(({ next, ctx }) => {
if (!ctx.dbUser || !ctx.activeAccountId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const activeMembership = ctx.dbUser.memberships.find(
membership => membership.account_id == ctx.activeAccountId
);
export const isAccountWithFeature = (feature: string) => t.middleware(({ next, ctx }) => { console.log(
if (!ctx.dbUser || !ctx.activeAccountId) { `isAccountWithFeature(${feature}) activeMembership?.account.features:${activeMembership?.account.features}`
throw new TRPCError({ code: 'UNAUTHORIZED' }); );
} if (!activeMembership?.account.features.includes(feature)) {
const activeMembership = ctx.dbUser.memberships.find(membership => membership.account_id == ctx.activeAccountId); throw new TRPCError({
code: 'UNAUTHORIZED',
message: `Account does not have the ${feature} feature`
});
}
console.log(`isAccountWithFeature(${feature}) activeMembership?.account.features:${activeMembership?.account.features}`); return next({ ctx });
if(!activeMembership?.account.features.includes(feature)){ });
throw new TRPCError({ code: 'UNAUTHORIZED', message: `Account does not have the ${feature} feature` });
}
return next({ ctx });
});
/** /**
* Procedures * Procedures
**/ **/
export const publicProcedure = t.procedure; export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed); export const protectedProcedure = t.procedure.use(isAuthed);
export const memberProcedure = protectedProcedure.use(isMemberWithAccessesForActiveAccountId([])); export const memberProcedure = protectedProcedure.use(
export const readWriteProcedure = protectedProcedure.use(isMemberWithAccessesForActiveAccountId([ACCOUNT_ACCESS.READ_WRITE, ACCOUNT_ACCESS.ADMIN, ACCOUNT_ACCESS.OWNER])); isMemberWithAccessesForActiveAccountId([])
export const adminProcedure = protectedProcedure.use(isMemberWithAccessesForActiveAccountId([ACCOUNT_ACCESS.ADMIN, ACCOUNT_ACCESS.OWNER])); );
export const ownerProcedure = protectedProcedure.use(isMemberWithAccessesForActiveAccountId([ACCOUNT_ACCESS.OWNER])); export const readWriteProcedure = protectedProcedure.use(
isMemberWithAccessesForActiveAccountId([
ACCOUNT_ACCESS.READ_WRITE,
ACCOUNT_ACCESS.ADMIN,
ACCOUNT_ACCESS.OWNER
])
);
export const adminProcedure = protectedProcedure.use(
isMemberWithAccessesForActiveAccountId([
ACCOUNT_ACCESS.ADMIN,
ACCOUNT_ACCESS.OWNER
])
);
export const ownerProcedure = protectedProcedure.use(
isMemberWithAccessesForActiveAccountId([ACCOUNT_ACCESS.OWNER])
);
export const accountHasSpecialFeature = isAccountWithFeature('SPECIAL_FEATURE'); export const accountHasSpecialFeature = isAccountWithFeature('SPECIAL_FEATURE');
export const router = t.router; export const router = t.router;

View File

@@ -1,6 +1,6 @@
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum'; import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
import { defineStore } from "pinia" import { defineStore } from 'pinia';
import { FullDBUser, MembershipWithUser } from "~~/lib/services/service.types"; import { FullDBUser, MembershipWithUser } from '~~/lib/services/service.types';
/* /*
This store manages User and Account state including the ActiveAccount This store manages User and Account state including the ActiveAccount
@@ -23,9 +23,9 @@ so that other routers can use them to filter results to the active user and acco
account account acccount* account account acccount*
*/ */
interface State { interface State {
dbUser: FullDBUser | null, dbUser: FullDBUser | null;
activeAccountId: number | null, activeAccountId: number | null;
activeAccountMembers: MembershipWithUser[] activeAccountMembers: MembershipWithUser[];
} }
export const useAccountStore = defineStore('account', { export const useAccountStore = defineStore('account', {
@@ -33,117 +33,155 @@ export const useAccountStore = defineStore('account', {
return { return {
dbUser: null, dbUser: null,
activeAccountId: null, activeAccountId: null,
activeAccountMembers: [], activeAccountMembers: []
} };
}, },
getters: { getters: {
activeMembership: (state) => state.dbUser?.memberships.find(m => m.account_id === state.activeAccountId) activeMembership: state =>
state.dbUser?.memberships.find(
m => m.account_id === state.activeAccountId
)
}, },
actions: { actions: {
async init(){ async init() {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
if(!this.dbUser){ if (!this.dbUser) {
const { dbUser } = await $client.auth.getDBUser.query(); const { dbUser } = await $client.auth.getDBUser.query();
if(dbUser){ if (dbUser) {
this.dbUser = dbUser; this.dbUser = dbUser;
} }
} }
if(!this.activeAccountId){ if (!this.activeAccountId) {
const { activeAccountId } = await $client.account.getActiveAccountId.query(); const { activeAccountId } =
if(activeAccountId){ await $client.account.getActiveAccountId.query();
if (activeAccountId) {
this.activeAccountId = activeAccountId; this.activeAccountId = activeAccountId;
} }
} }
}, },
signout(){ signout() {
this.dbUser = null; this.dbUser = null;
this.activeAccountId = null; this.activeAccountId = null;
this.activeAccountMembers = []; this.activeAccountMembers = [];
}, },
async getActiveAccountMembers(){ async getActiveAccountMembers() {
if(this.activeMembership && (this.activeMembership.access === ACCOUNT_ACCESS.ADMIN || this.activeMembership.access === ACCOUNT_ACCESS.OWNER)){ if (
this.activeMembership &&
(this.activeMembership.access === ACCOUNT_ACCESS.ADMIN ||
this.activeMembership.access === ACCOUNT_ACCESS.OWNER)
) {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { data: memberships } = await $client.account.getAccountMembers.useQuery(); const { data: memberships } =
if(memberships.value?.memberships){ await $client.account.getAccountMembers.useQuery();
if (memberships.value?.memberships) {
this.activeAccountMembers = memberships.value?.memberships; this.activeAccountMembers = memberships.value?.memberships;
} }
} }
}, },
async changeActiveAccount(account_id: number){ async changeActiveAccount(account_id: number) {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
await $client.account.changeActiveAccount.mutate({account_id}); // sets active account on context for other routers and sets the preference in a cookie await $client.account.changeActiveAccount.mutate({ account_id }); // sets active account on context for other routers and sets the preference in a cookie
this.activeAccountId = account_id; // because this is used as a trigger to some other components, NEEDS TO BE AFTER THE MUTATE CALL this.activeAccountId = account_id; // because this is used as a trigger to some other components, NEEDS TO BE AFTER THE MUTATE CALL
await this.getActiveAccountMembers(); // these relate to the active account and need to ber re-fetched await this.getActiveAccountMembers(); // these relate to the active account and need to ber re-fetched
}, },
async changeAccountName(new_name: string){ async changeAccountName(new_name: string) {
if(!this.activeMembership){ return; } if (!this.activeMembership) {
return;
}
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { account } = await $client.account.changeAccountName.mutate({ new_name }); const { account } = await $client.account.changeAccountName.mutate({
if(account){ new_name
});
if (account) {
this.activeMembership.account.name = account.name; this.activeMembership.account.name = account.name;
} }
}, },
async acceptPendingMembership(membership_id: number){ async acceptPendingMembership(membership_id: number) {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { data: membership } = await $client.account.acceptPendingMembership.useQuery({ membership_id }); const { data: membership } =
await $client.account.acceptPendingMembership.useQuery({
membership_id
});
if(membership.value && membership.value.membership?.pending === false){ if (membership.value && membership.value.membership?.pending === false) {
for(const m of this.activeAccountMembers){ for (const m of this.activeAccountMembers) {
if(m.id === membership_id){ if (m.id === membership_id) {
m.pending = false; m.pending = false;
} }
} }
} }
}, },
async rejectPendingMembership(membership_id: number){ async rejectPendingMembership(membership_id: number) {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { data: membership } = await $client.account.rejectPendingMembership.useQuery({ membership_id }); const { data: membership } =
await $client.account.rejectPendingMembership.useQuery({
membership_id
});
if(membership.value){ if (membership.value) {
this.activeAccountMembers = this.activeAccountMembers.filter(m => m.id !== membership_id); this.activeAccountMembers = this.activeAccountMembers.filter(
m => m.id !== membership_id
);
} }
}, },
async deleteMembership(membership_id: number){ async deleteMembership(membership_id: number) {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { data: membership } = await $client.account.deleteMembership.useQuery({ membership_id }); const { data: membership } =
await $client.account.deleteMembership.useQuery({ membership_id });
if(membership.value){ if (membership.value) {
this.activeAccountMembers = this.activeAccountMembers.filter(m => m.id !== membership_id); this.activeAccountMembers = this.activeAccountMembers.filter(
m => m.id !== membership_id
);
} }
}, },
async rotateJoinPassword(){ async rotateJoinPassword() {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { account } = await $client.account.rotateJoinPassword.mutate(); const { account } = await $client.account.rotateJoinPassword.mutate();
if(account && this.activeMembership){ if (account && this.activeMembership) {
this.activeMembership.account = account; this.activeMembership.account = account;
} }
}, },
async joinUserToAccountPending(account_id: number){ async joinUserToAccountPending(account_id: number) {
if(!this.dbUser) { return; } if (!this.dbUser) {
return;
}
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { membership } = await $client.account.joinUserToAccountPending.mutate({account_id, user_id: this.dbUser.id}); const { membership } =
if(membership && this.activeMembership){ await $client.account.joinUserToAccountPending.mutate({
account_id,
user_id: this.dbUser.id
});
if (membership && this.activeMembership) {
this.dbUser?.memberships.push(membership); this.dbUser?.memberships.push(membership);
} }
}, },
async changeUserAccessWithinAccount(user_id: number, access: ACCOUNT_ACCESS){ async changeUserAccessWithinAccount(
user_id: number,
access: ACCOUNT_ACCESS
) {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { membership } = await $client.account.changeUserAccessWithinAccount.mutate({ user_id, access }); const { membership } =
if(membership){ await $client.account.changeUserAccessWithinAccount.mutate({
for(const m of this.activeAccountMembers){ user_id,
if(m.id === membership.id){ access
});
if (membership) {
for (const m of this.activeAccountMembers) {
if (m.id === membership.id) {
m.access = membership.access; m.access = membership.access;
} }
} }
} }
}, },
async claimOwnershipOfAccount(){ async claimOwnershipOfAccount() {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { memberships } = await $client.account.claimOwnershipOfAccount.mutate(); const { memberships } =
if(memberships){ await $client.account.claimOwnershipOfAccount.mutate();
if (memberships) {
this.activeAccountMembers = memberships; this.activeAccountMembers = memberships;
this.activeMembership!.access = ACCOUNT_ACCESS.OWNER this.activeMembership!.access = ACCOUNT_ACCESS.OWNER;
} }
} }
} }

View File

@@ -1,6 +1,6 @@
import { Note } from ".prisma/client" import { Note } from '.prisma/client';
import { defineStore, storeToRefs } from "pinia" import { defineStore, storeToRefs } from 'pinia';
import { Ref } from "vue"; import { Ref } from 'vue';
/* /*
Note) the Notes Store needs to be a 'Setup Store' (https://pinia.vuejs.org/core-concepts/#setup-stores) Note) the Notes Store needs to be a 'Setup Store' (https://pinia.vuejs.org/core-concepts/#setup-stores)
@@ -9,7 +9,7 @@ If the UI does not need to dynamically respond to a change in the active Account
then an Options store can be used. then an Options store can be used.
*/ */
export const useNotesStore = defineStore('notes', () => { export const useNotesStore = defineStore('notes', () => {
const accountStore = useAccountStore() const accountStore = useAccountStore();
const { activeAccountId } = storeToRefs(accountStore); const { activeAccountId } = storeToRefs(accountStore);
let _notes: Ref<Note[]> = ref([]); let _notes: Ref<Note[]> = ref([]);
@@ -17,37 +17,45 @@ export const useNotesStore = defineStore('notes', () => {
async function fetchNotesForCurrentUser() { async function fetchNotesForCurrentUser() {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { notes } = await $client.notes.getForActiveAccount.query(); const { notes } = await $client.notes.getForActiveAccount.query();
if(notes){ if (notes) {
_notes.value = notes; _notes.value = notes;
} }
} }
async function createNote(note_text: string) { async function createNote(note_text: string) {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { note } = await $client.notes.createNote.mutate({note_text}); const { note } = await $client.notes.createNote.mutate({ note_text });
if(note){ if (note) {
_notes.value.push(note); _notes.value.push(note);
} }
} }
async function deleteNote(note_id: number) { async function deleteNote(note_id: number) {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { note } = await $client.notes.deleteNote.mutate({note_id}); const { note } = await $client.notes.deleteNote.mutate({ note_id });
if(note){ if (note) {
_notes.value = _notes.value.filter(n => n.id !== note.id); _notes.value = _notes.value.filter(n => n.id !== note.id);
} }
} }
async function generateAINoteFromPrompt(user_prompt: string) { async function generateAINoteFromPrompt(user_prompt: string) {
const { $client } = useNuxtApp(); const { $client } = useNuxtApp();
const { noteText } = await $client.notes.generateAINoteFromPrompt.query({user_prompt}); const { noteText } = await $client.notes.generateAINoteFromPrompt.query({
return noteText?noteText:''; user_prompt
});
return noteText ? noteText : '';
} }
// if the active account changes, fetch notes again (i.e dynamic.. probabl overkill) // if the active account changes, fetch notes again (i.e dynamic.. probabl overkill)
watch(activeAccountId, async (val, oldVal)=> { watch(activeAccountId, async (val, oldVal) => {
await fetchNotesForCurrentUser() await fetchNotesForCurrentUser();
}); });
return { notes: _notes, fetchNotesForCurrentUser, createNote, deleteNote, generateAINoteFromPrompt} return {
notes: _notes,
fetchNotesForCurrentUser,
createNote,
deleteNote,
generateAINoteFromPrompt
};
}); });

View File

@@ -1,45 +1,51 @@
import { defineStore } from "pinia" import { defineStore } from 'pinia';
/* /*
This store manages User and Account state including the ActiveAccount This store manages User and Account state including the ActiveAccount
It is used in the Account administration page and the header due to it's account switching features. It is used in the Account administration page and the header due to it's account switching features.
*/ */
export interface Notification{ export interface Notification {
message: string; message: string;
type: NotificationType; type: NotificationType;
notifyTime: number; notifyTime: number;
} }
export enum NotificationType{ export enum NotificationType {
Info, Info,
Success, Success,
Warning, Warning,
Error, Error
} }
interface State { interface State {
notifications: Notification[], notifications: Notification[];
notificationsArchive: Notification[], notificationsArchive: Notification[];
} }
export const useNotifyStore = defineStore('notify', { export const useNotifyStore = defineStore('notify', {
state: (): State => { state: (): State => {
return { return {
notifications: [], notifications: [],
notificationsArchive: [], notificationsArchive: []
} };
}, },
actions: { actions: {
notify(messageOrError: unknown, type:NotificationType){ notify(messageOrError: unknown, type: NotificationType) {
let message: string = ""; let message: string = '';
if (messageOrError instanceof Error) message = messageOrError.message; if (messageOrError instanceof Error) message = messageOrError.message;
if (typeof messageOrError === "string") message = messageOrError; if (typeof messageOrError === 'string') message = messageOrError;
const notification: Notification = {message, type, notifyTime: Date.now()}; const notification: Notification = {
message,
type,
notifyTime: Date.now()
};
this.notifications.push(notification); this.notifications.push(notification);
setTimeout(this.removeNotification.bind(this), 5000, notification); setTimeout(this.removeNotification.bind(this), 5000, notification);
}, },
removeNotification(notification: Notification){ removeNotification(notification: Notification) {
this.notifications = this.notifications.filter(n => n.notifyTime != notification.notifyTime); this.notifications = this.notifications.filter(
}, n => n.notifyTime != notification.notifyTime
);
}
} }
}); });

View File

@@ -1,5 +1,5 @@
export default { export default {
plugins:[require("@tailwindcss/typography"), require("daisyui")], plugins: [require("@tailwindcss/typography"), require("daisyui")],
daisyui: { daisyui: {
styled: true, styled: true,
themes: ["acid", "night"], themes: ["acid", "night"],