prettier fixes #16
This commit is contained in:
16
.prettierignore
Normal file
16
.prettierignore
Normal 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
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"bracketSameLine": true,
|
||||
"vueIndentScriptAndStyle": true,
|
||||
"arrowParens": "avoid",
|
||||
"trailingComma": "none"
|
||||
}
|
||||
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div class="sticky z-50 bottom-0 p-4 bg-base-200">
|
||||
<span><NuxtLink to="/terms">Terms Of Service</NuxtLink></span>
|
||||
<span> | <NuxtLink to="/privacy">Privacy</NuxtLink></span>
|
||||
<span> | <button type="button" data-cc="c-settings">Cookie settings</button></span>
|
||||
<NuxtLink to="/terms">Terms Of Service</NuxtLink>
|
||||
<span class="px-2">|</span>
|
||||
<NuxtLink to="/privacy">Privacy</NuxtLink>
|
||||
<span class="px-2">|</span>
|
||||
<button type="button" data-cc="c-settings">Cookie settings</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,19 +4,34 @@
|
||||
|
||||
<template>
|
||||
<div class="navbar bg-base-100">
|
||||
<Notifications/>
|
||||
<Notifications />
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
<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>
|
||||
<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><NuxtLink to="/pricing">Pricing</NuxtLink></li>
|
||||
<li v-if="!user"><NuxtLink to="/signin">Sign In</NuxtLink></li>
|
||||
</ul>
|
||||
</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 class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
@@ -24,9 +39,15 @@
|
||||
<li><NuxtLink to="/pricing">Pricing</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"><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>
|
||||
</div>
|
||||
<UserAccount v-if="user" :user="user"/>
|
||||
<UserAccount v-if="user" :user="user" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,18 +21,18 @@
|
||||
|
||||
// props for which buttons to show
|
||||
interface Props {
|
||||
showOk?: boolean
|
||||
showCancel?: boolean
|
||||
showOk?: boolean;
|
||||
showCancel?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showOk: true,
|
||||
showCancel: false,
|
||||
})
|
||||
showCancel: false
|
||||
});
|
||||
|
||||
// open event (exposed to parent)
|
||||
const open = () => {
|
||||
modalIsVisible.value = true;
|
||||
}
|
||||
};
|
||||
defineExpose({ open });
|
||||
|
||||
// close events emitted on modal close
|
||||
@@ -40,16 +40,20 @@
|
||||
const closeOk = () => {
|
||||
emit('closeOk');
|
||||
modalIsVisible.value = false;
|
||||
}
|
||||
};
|
||||
const closeCancel = () => {
|
||||
emit('closeCancel');
|
||||
modalIsVisible.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<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-box">
|
||||
<slot />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NotificationType } from '#imports';
|
||||
import { storeToRefs } from 'pinia';
|
||||
@@ -9,20 +8,21 @@
|
||||
const classNameForType = (type: NotificationType) => {
|
||||
switch (type) {
|
||||
case NotificationType.Info:
|
||||
return "alert alert-info";
|
||||
return 'alert alert-info';
|
||||
case NotificationType.Success:
|
||||
return "alert alert-success";
|
||||
return 'alert alert-success';
|
||||
case NotificationType.Warning:
|
||||
return "alert alert-warning";
|
||||
return 'alert alert-warning';
|
||||
case NotificationType.Error:
|
||||
return "alert alert-error";
|
||||
return 'alert alert-error';
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<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>
|
||||
<button
|
||||
@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"
|
||||
aria-label="Close">
|
||||
<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>
|
||||
<span> {{notification.message}}</span>
|
||||
<span> {{ notification.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
required: true
|
||||
}
|
||||
});
|
||||
const { user } = props;
|
||||
</script>
|
||||
@@ -13,15 +13,23 @@
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-circle avatar">
|
||||
<div class="w-10 rounded-full">
|
||||
<img 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"/>
|
||||
<img
|
||||
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>
|
||||
</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><NuxtLink to="/account">Account</NuxtLink></li>
|
||||
<li><UserAccountSignout/></li>
|
||||
<UserAccountSwitch/>
|
||||
<li><UserAccountSignout /></li>
|
||||
<UserAccountSwitch />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
const supabase = useSupabaseAuthClient();
|
||||
const accountStore = useAccountStore();
|
||||
|
||||
onMounted(async () => {
|
||||
await accountStore.init()
|
||||
await accountStore.init();
|
||||
});
|
||||
|
||||
async function signout() {
|
||||
await supabase.auth.signOut();
|
||||
if(accountStore){
|
||||
if (accountStore) {
|
||||
accountStore.signout();
|
||||
}
|
||||
navigateTo('/', {replace: true});
|
||||
navigateTo('/', { replace: true });
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const accountStore = useAccountStore();
|
||||
const { dbUser, activeAccountId } = storeToRefs(accountStore);
|
||||
|
||||
onMounted(async () => {
|
||||
await accountStore.init()
|
||||
await accountStore.init();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="dbUser?.memberships && dbUser?.memberships.length > 1">
|
||||
<li>Switch Account</li>
|
||||
<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>
|
||||
<span v-if="membership.pending">{{ membership.account.name }} (pending)</span>
|
||||
<a
|
||||
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>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,6 +1,13 @@
|
||||
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
|
||||
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 { UtilService } from './util.service';
|
||||
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({
|
||||
where: { join_password },
|
||||
...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({
|
||||
where: { id: account_id },
|
||||
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({
|
||||
where: {stripe_customer_id}
|
||||
where: { stripe_customer_id }
|
||||
});
|
||||
|
||||
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
|
||||
return await prisma_client.account.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
stripe_subscription_id,
|
||||
current_period_ends,
|
||||
ai_gen_count:0,
|
||||
ai_gen_count: 0
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -70,72 +87,82 @@ export default class AccountService {
|
||||
max_members: paid_plan.max_members,
|
||||
plan_name: paid_plan.name,
|
||||
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({
|
||||
where: {
|
||||
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`);
|
||||
}
|
||||
|
||||
return await prisma_client.membership.update({
|
||||
where: {
|
||||
id: membership_id,
|
||||
id: membership_id
|
||||
},
|
||||
data: {
|
||||
pending: false
|
||||
},
|
||||
...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({
|
||||
where: {
|
||||
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`);
|
||||
}
|
||||
|
||||
return await prisma_client.membership.delete({
|
||||
where: {
|
||||
id: membership_id,
|
||||
id: membership_id
|
||||
},
|
||||
...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({
|
||||
where: {
|
||||
id: account_id,
|
||||
},
|
||||
include:{
|
||||
members: true,
|
||||
}
|
||||
where: {
|
||||
id: account_id
|
||||
},
|
||||
include: {
|
||||
members: true
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
if(account?.members && account?.members?.length >= account?.max_members){
|
||||
throw new Error(`Too Many Members, Account only permits ${account?.max_members} members.`);
|
||||
if (account?.members && account?.members?.length >= account?.max_members) {
|
||||
throw new Error(
|
||||
`Too Many Members, Account only permits ${account?.max_members} members.`
|
||||
);
|
||||
}
|
||||
|
||||
if(account?.members){
|
||||
for(const member of account.members){
|
||||
if(member.user_id === user_id){
|
||||
if (account?.members) {
|
||||
for (const member of account.members) {
|
||||
if (member.user_id === user_id) {
|
||||
throw new Error(`User is already a member`);
|
||||
}
|
||||
}
|
||||
@@ -154,21 +181,23 @@ export default class AccountService {
|
||||
|
||||
async changeAccountName(account_id: number, new_name: string) {
|
||||
return prisma_client.account.update({
|
||||
where: { id: account_id},
|
||||
where: { id: account_id },
|
||||
data: {
|
||||
name: new_name,
|
||||
name: new_name
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
where: { id: account_id},
|
||||
where: { id: account_id },
|
||||
data: {
|
||||
plan_id: plan_id,
|
||||
features: plan.features,
|
||||
max_notes: plan.max_notes,
|
||||
max_notes: plan.max_notes
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -179,7 +208,7 @@ export default class AccountService {
|
||||
numbers: true
|
||||
});
|
||||
return prisma_client.account.update({
|
||||
where: { id: account_id},
|
||||
where: { id: account_id },
|
||||
data: { join_password }
|
||||
});
|
||||
}
|
||||
@@ -188,14 +217,17 @@ export default class AccountService {
|
||||
// User must already be an ADMIN for the Account
|
||||
// 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.
|
||||
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({
|
||||
where: {
|
||||
user_id_account_id: {
|
||||
user_id: user_id,
|
||||
account_id: account_id,
|
||||
account_id: account_id
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
if (membership.access === ACCOUNT_ACCESS.OWNER) {
|
||||
@@ -207,20 +239,20 @@ export default class AccountService {
|
||||
const existing_owner_memberships = await prisma_client.membership.findMany({
|
||||
where: {
|
||||
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({
|
||||
where: {
|
||||
user_id_account_id: {
|
||||
user_id: existing_owner_membership.user_id,
|
||||
account_id: account_id,
|
||||
account_id: account_id
|
||||
}
|
||||
},
|
||||
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: {
|
||||
user_id_account_id: {
|
||||
user_id: user_id,
|
||||
account_id: account_id,
|
||||
account_id: account_id
|
||||
}
|
||||
},
|
||||
data: {
|
||||
access: ACCOUNT_ACCESS.OWNER,
|
||||
},
|
||||
access: ACCOUNT_ACCESS.OWNER
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
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) {
|
||||
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({
|
||||
where: {
|
||||
user_id_account_id: {
|
||||
user_id: user_id,
|
||||
account_id: account_id,
|
||||
account_id: account_id
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
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({
|
||||
where: {
|
||||
user_id_account_id: {
|
||||
user_id: user_id,
|
||||
account_id: account_id,
|
||||
account_id: account_id
|
||||
}
|
||||
},
|
||||
data: {
|
||||
access: access,
|
||||
access: access
|
||||
},
|
||||
include: {
|
||||
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({
|
||||
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({
|
||||
where: { id: account.id },
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
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({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
ai_gen_count: account.ai_gen_count + 1,
|
||||
ai_gen_count: account.ai_gen_count + 1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import generator from 'generate-password-ts';
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
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({
|
||||
where: { supabase_uid },
|
||||
...fullDBUser
|
||||
@@ -21,14 +23,20 @@ export default class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async createUser( supabase_uid: string, display_name: string, email: string ): Promise<FullDBUser | null> {
|
||||
const trialPlan = await prisma_client.plan.findFirstOrThrow({ where: { name: config.initialPlanName}});
|
||||
async createUser(
|
||||
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({
|
||||
length: 10,
|
||||
numbers: true
|
||||
});
|
||||
return prisma_client.user.create({
|
||||
data:{
|
||||
data: {
|
||||
supabase_uid: supabase_uid,
|
||||
display_name: display_name,
|
||||
email: email,
|
||||
@@ -37,13 +45,16 @@ export default class AuthService {
|
||||
account: {
|
||||
create: {
|
||||
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,
|
||||
features: trialPlan.features,
|
||||
max_notes: trialPlan.max_notes,
|
||||
max_members: trialPlan.max_members,
|
||||
plan_name: trialPlan.name,
|
||||
join_password: join_password,
|
||||
join_password: join_password
|
||||
}
|
||||
},
|
||||
access: ACCOUNT_ACCESS.OWNER
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export class AccountLimitError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, AccountLimitError.prototype);
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, AccountLimitError.prototype);
|
||||
}
|
||||
}
|
||||
@@ -16,17 +16,19 @@ export default class NotesService {
|
||||
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({
|
||||
where: { id: account_id},
|
||||
include: { notes: true}
|
||||
where: { id: account_id },
|
||||
include: { notes: true }
|
||||
});
|
||||
|
||||
if(account.notes.length>= account.max_notes){
|
||||
throw new AccountLimitError('Note Limit reached, no new notes can be added');
|
||||
if (account.notes.length >= account.max_notes) {
|
||||
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) {
|
||||
@@ -44,14 +46,14 @@ export default class NotesService {
|
||||
const prompt = `
|
||||
Write an interesting short note about ${userPrompt}.
|
||||
Restrict the note to a single paragraph.
|
||||
`
|
||||
`;
|
||||
const completion = await openai.createCompletion({
|
||||
model: "text-davinci-003",
|
||||
model: 'text-davinci-003',
|
||||
prompt,
|
||||
temperature: 0.6,
|
||||
stop: "\n\n",
|
||||
stop: '\n\n',
|
||||
max_tokens: 1000,
|
||||
n: 1,
|
||||
n: 1
|
||||
});
|
||||
|
||||
await accountService.incrementAIGenCount(account);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Configuration, OpenAIApi } from "openai";
|
||||
import { Configuration, OpenAIApi } from 'openai';
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const configuration = new Configuration({
|
||||
apiKey: config.openAIKey,
|
||||
apiKey: config.openAIKey
|
||||
});
|
||||
|
||||
export const openai = new OpenAIApi(configuration);
|
||||
@@ -1,25 +1,39 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export const membershipWithAccount = Prisma.validator<Prisma.MembershipArgs>()({
|
||||
include: { account: true },
|
||||
})
|
||||
export type MembershipWithAccount = Prisma.MembershipGetPayload<typeof membershipWithAccount>
|
||||
include: { account: true }
|
||||
});
|
||||
export type MembershipWithAccount = Prisma.MembershipGetPayload<
|
||||
typeof membershipWithAccount
|
||||
>;
|
||||
|
||||
export const membershipWithUser = Prisma.validator<Prisma.MembershipArgs>()({
|
||||
include: { user: true },
|
||||
})
|
||||
export type MembershipWithUser = Prisma.MembershipGetPayload<typeof membershipWithUser>
|
||||
include: { user: true }
|
||||
});
|
||||
export type MembershipWithUser = Prisma.MembershipGetPayload<
|
||||
typeof membershipWithUser
|
||||
>;
|
||||
|
||||
export const fullDBUser = Prisma.validator<Prisma.UserArgs>()({
|
||||
include: { memberships: {include: {
|
||||
account: true
|
||||
}}}
|
||||
include: {
|
||||
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>()({
|
||||
include: { members: {include: {
|
||||
user: true
|
||||
}} }
|
||||
})
|
||||
export type AccountWithMembers = Prisma.AccountGetPayload<typeof accountWithMembers> //TODO - I wonder if this could just be a list of full memberships
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
export type AccountWithMembers = Prisma.AccountGetPayload<
|
||||
typeof accountWithMembers
|
||||
>; //TODO - I wonder if this could just be a list of full memberships
|
||||
|
||||
@@ -9,14 +9,14 @@ export class UtilService {
|
||||
}
|
||||
|
||||
public static getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
public static stringifySafely(obj: any) {
|
||||
let cache: any[] = [];
|
||||
let str = JSON.stringify(obj, function(key, value) {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
let str = JSON.stringify(obj, function (key, value) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (cache.indexOf(value) !== -1) {
|
||||
// Circular reference found, discard key
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
const user = useSupabaseUser()
|
||||
const user = useSupabaseUser();
|
||||
|
||||
if (!user.value) {
|
||||
return navigateTo('/')
|
||||
return navigateTo('/');
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -7,19 +7,24 @@ export default defineNuxtConfig({
|
||||
typescript: {
|
||||
shim: false
|
||||
},
|
||||
modules: ['@nuxtjs/supabase', '@pinia/nuxt', '@nuxtjs/tailwindcss', 'nuxt-icon'],
|
||||
modules: [
|
||||
'@nuxtjs/supabase',
|
||||
'@pinia/nuxt',
|
||||
'@nuxtjs/tailwindcss',
|
||||
'nuxt-icon'
|
||||
],
|
||||
imports: {
|
||||
dirs: ['./stores'],
|
||||
dirs: ['./stores']
|
||||
},
|
||||
app:{
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: {
|
||||
lang: 'en',
|
||||
lang: 'en'
|
||||
},
|
||||
title: 'SupaNuxt SaaS',
|
||||
},
|
||||
title: 'SupaNuxt SaaS'
|
||||
}
|
||||
},
|
||||
runtimeConfig:{
|
||||
runtimeConfig: {
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
|
||||
stripeEndpointSecret: process.env.STRIPE_ENDPOINT_SECRET,
|
||||
subscriptionGraceDays: 3,
|
||||
@@ -28,7 +33,7 @@ export default defineNuxtConfig({
|
||||
openAIKey: process.env.OPENAI_API_KEY,
|
||||
public: {
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
|
||||
|
||||
const accountStore = useAccountStore();
|
||||
const { activeMembership, activeAccountMembers } = storeToRefs(accountStore)
|
||||
const { activeMembership, activeAccountMembers } = storeToRefs(accountStore);
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const newAccountName = ref("");
|
||||
const newAccountName = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
await accountStore.init();
|
||||
@@ -14,8 +14,12 @@
|
||||
});
|
||||
|
||||
function formatDate(date: Date | undefined) {
|
||||
if (!date) { return ""; }
|
||||
return new Intl.DateTimeFormat('default', { dateStyle: 'long' }).format(date);
|
||||
if (!date) {
|
||||
return '';
|
||||
}
|
||||
return new Intl.DateTimeFormat('default', { dateStyle: 'long' }).format(
|
||||
date
|
||||
);
|
||||
}
|
||||
|
||||
function joinURL() {
|
||||
@@ -25,7 +29,9 @@
|
||||
<template>
|
||||
<div class="container mx-auto p-6">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -34,24 +40,39 @@
|
||||
<span class="font-bold w-32">Account Name:</span>
|
||||
<span>{{ activeMembership?.account.name }}</span>
|
||||
<template
|
||||
v-if="activeMembership && (activeMembership.access === ACCOUNT_ACCESS.OWNER || activeMembership.access === ACCOUNT_ACCESS.ADMIN)">
|
||||
<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>
|
||||
v-if="
|
||||
activeMembership &&
|
||||
(activeMembership.access === ACCOUNT_ACCESS.OWNER ||
|
||||
activeMembership.access === ACCOUNT_ACCESS.ADMIN)
|
||||
">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-center">
|
||||
<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 class="flex gap-4 items-center">
|
||||
<span class="font-bold w-32">Permitted Features:</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -62,7 +83,10 @@
|
||||
|
||||
<div class="flex gap-4 items-center">
|
||||
<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 class="flex gap-4 items-center">
|
||||
@@ -72,9 +96,15 @@
|
||||
|
||||
<div class="flex gap-4 items-center">
|
||||
<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>
|
||||
<button @click.prevent="accountStore.claimOwnershipOfAccount()"
|
||||
v-if="activeMembership && activeMembership.access === ACCOUNT_ACCESS.ADMIN "
|
||||
<span
|
||||
class="bg-green-500 text-white font-semibold py-1 px-2 rounded-full">
|
||||
{{ 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">
|
||||
Claim Ownership
|
||||
</button>
|
||||
@@ -88,47 +118,109 @@
|
||||
<div class="flex gap-4 items-center">
|
||||
<span class="font-bold w-32">Join Link:</span>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input disabled type="text" 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>
|
||||
<input
|
||||
disabled
|
||||
type="text"
|
||||
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 class="flex flex-col gap-4">
|
||||
<h2 class="text-lg font-bold">Members</h2>
|
||||
<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 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>
|
||||
<button @click.prevent="accountStore.acceptPendingMembership(accountMember.id)"
|
||||
v-if="accountMember.pending && activeMembership && (activeMembership.access === ACCOUNT_ACCESS.OWNER || activeMembership.access === ACCOUNT_ACCESS.ADMIN)"
|
||||
<button
|
||||
@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">
|
||||
Accept Pending Membership
|
||||
</button>
|
||||
<button @click.prevent="accountStore.rejectPendingMembership(accountMember.id)"
|
||||
v-if="accountMember.pending && activeMembership && (activeMembership.access === ACCOUNT_ACCESS.OWNER || activeMembership.access === ACCOUNT_ACCESS.ADMIN)"
|
||||
<button
|
||||
@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">
|
||||
Reject Pending Membership
|
||||
</button>
|
||||
<button @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"
|
||||
<button
|
||||
@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">
|
||||
Promote to Read/Write
|
||||
</button>
|
||||
<button @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"
|
||||
<button
|
||||
@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">
|
||||
Promote to Admin
|
||||
</button>
|
||||
<button @click.prevent="accountStore.deleteMembership(accountMember.id)"
|
||||
v-if="activeMembership && activeMembership.access === ACCOUNT_ACCESS.OWNER && accountMember.access !== ACCOUNT_ACCESS.OWNER && !accountMember.pending"
|
||||
<button
|
||||
@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">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></template>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<template>
|
||||
<div class="prose lg:prose-xl m-5">
|
||||
<p>
|
||||
We are sorry that you canceled your transaction!
|
||||
</p>
|
||||
<p>We are sorry that you canceled your transaction!</p>
|
||||
<p>
|
||||
<NuxtLink to="/pricing">Pricing</NuxtLink>
|
||||
</p>
|
||||
|
||||
@@ -3,22 +3,24 @@
|
||||
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth'],
|
||||
middleware: ['auth']
|
||||
});
|
||||
|
||||
const accountStore = useAccountStore();
|
||||
const { activeMembership } = storeToRefs(accountStore)
|
||||
const { activeMembership } = storeToRefs(accountStore);
|
||||
const notesStore = useNotesStore();
|
||||
const { notes } = storeToRefs(notesStore); // ensure the notes list is reactive
|
||||
const newNoteText = ref('')
|
||||
const { notes } = storeToRefs(notesStore); // ensure the notes list is reactive
|
||||
const newNoteText = ref('');
|
||||
|
||||
async function addNote(){
|
||||
await notesStore.createNote(newNoteText.value)
|
||||
async function addNote() {
|
||||
await notesStore.createNote(newNoteText.value);
|
||||
newNoteText.value = '';
|
||||
}
|
||||
|
||||
async function genNote(){
|
||||
const genNoteText = await notesStore.generateAINoteFromPrompt(newNoteText.value)
|
||||
async function genNote() {
|
||||
const genNoteText = await notesStore.generateAINoteFromPrompt(
|
||||
newNoteText.value
|
||||
);
|
||||
newNoteText.value = genNoteText;
|
||||
}
|
||||
|
||||
@@ -28,31 +30,59 @@
|
||||
});
|
||||
</script>
|
||||
<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">
|
||||
<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 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
|
||||
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">
|
||||
<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">
|
||||
Add
|
||||
</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">
|
||||
Gen
|
||||
<Icon name="mdi:magic" class="h-6 w-6"/>
|
||||
<Icon name="mdi:magic" class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button @click.prevent="notesStore.deleteNote(note.id)"
|
||||
v-if="activeMembership && (activeMembership.access === ACCOUNT_ACCESS.ADMIN || activeMembership.access === ACCOUNT_ACCESS.OWNER)"
|
||||
<button
|
||||
@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">
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<template>
|
||||
<div class="prose lg:prose-xl m-5">
|
||||
<p>
|
||||
We are sorry that you were unable to subscribe.
|
||||
</p>
|
||||
<p>We are sorry that you were unable to subscribe.</p>
|
||||
<p>
|
||||
<NuxtLink to="/pricing">Pricing</NuxtLink>
|
||||
</p>
|
||||
|
||||
@@ -3,23 +3,30 @@
|
||||
const config = useRuntimeConfig();
|
||||
const notifyStore = useNotifyStore();
|
||||
|
||||
const loading = ref(false)
|
||||
const email = ref('')
|
||||
const loading = ref(false);
|
||||
const email = ref('');
|
||||
|
||||
const sendResetPasswordLink = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const { data, error } = await supabase.auth.resetPasswordForEmail(email.value, {
|
||||
redirectTo: `${config.public.siteRootUrl}/resetpassword`,
|
||||
})
|
||||
if (error) throw error
|
||||
else notifyStore.notify("Password Reset link sent, check your email.", NotificationType.Success);
|
||||
loading.value = true;
|
||||
const { data, error } = await supabase.auth.resetPasswordForEmail(
|
||||
email.value,
|
||||
{
|
||||
redirectTo: `${config.public.siteRootUrl}/resetpassword`
|
||||
}
|
||||
);
|
||||
if (error) throw error;
|
||||
else
|
||||
notifyStore.notify(
|
||||
'Password Reset link sent, check your email.',
|
||||
NotificationType.Success
|
||||
);
|
||||
} catch (error) {
|
||||
notifyStore.notify(error, NotificationType.Error);
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<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">
|
||||
<div>
|
||||
<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"
|
||||
placeholder="Enter your email" required>
|
||||
<input
|
||||
v-model="email"
|
||||
id="email"
|
||||
type="email"
|
||||
class="w-full p-2 border border-gray-400 rounded-md"
|
||||
placeholder="Enter your email"
|
||||
required />
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
169
pages/index.vue
169
pages/index.vue
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
const user = useSupabaseUser()
|
||||
const user = useSupabaseUser();
|
||||
watchEffect(() => {
|
||||
if (user.value) {
|
||||
navigateTo('/dashboard', {replace: true})
|
||||
navigateTo('/dashboard', { replace: true });
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="container mx-auto m-5">
|
||||
@@ -13,57 +13,72 @@ const user = useSupabaseUser()
|
||||
<div class="container mx-auto">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-16">
|
||||
<div class="m-5">
|
||||
<h1 class="text-5xl font-bold mb-4">
|
||||
Build Your Next SaaS Faster
|
||||
</h1>
|
||||
<h1 class="text-5xl font-bold mb-4">Build Your Next SaaS Faster</h1>
|
||||
<p class="text-gray-700 text-lg mb-8">
|
||||
With SupaNuxt SaaS, you can easily get started building your
|
||||
next web application. Our pre-configured tech stack and
|
||||
industry leading features make it easy to get up and running in no time. Look! this guy is working so fast,
|
||||
his hands are just a blur.. you could be this fast.
|
||||
With SupaNuxt SaaS, you can easily get started building your next
|
||||
web application. Our pre-configured tech stack and industry
|
||||
leading features make it easy to get up and running in no time.
|
||||
Look! this guy is working so fast, his hands are just a blur.. you
|
||||
could be this fast.
|
||||
</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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="py-12">
|
||||
<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="container px-4 mx-auto">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row items-center mb-16">
|
||||
|
||||
<div class="md:w-full">
|
||||
<ul class="grid grid-cols-3 gap-10 list-none">
|
||||
<li>
|
||||
<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>
|
||||
<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>
|
||||
<Icon name="skill-icons:supabase-dark" class="h-12 w-12 mb-2" />
|
||||
<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>
|
||||
<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>
|
||||
<p class="mt-2 text-base text-gray-500">Relational Database</p>
|
||||
</li>
|
||||
<li>
|
||||
<Icon name="logos:prisma" class="h-12 w-12 mb-2" />
|
||||
<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>
|
||||
<Icon name="simple-icons:trpc" class="h-12 w-12 mb-2" />
|
||||
<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>
|
||||
<Icon name="skill-icons:vuejs-dark" class="h-12 w-12 mb-2" />
|
||||
@@ -73,104 +88,148 @@ const user = useSupabaseUser()
|
||||
<li>
|
||||
<Icon name="logos:stripe" class="h-12 w-12 mb-2" />
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<Icon name="logos:openai-icon" class="h-12 w-12 mb-2" />
|
||||
<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>
|
||||
</ul>
|
||||
</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>
|
||||
</div>
|
||||
<!-- User Management (text left) -->
|
||||
<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">
|
||||
<img 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">
|
||||
<img
|
||||
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 class="md:w-1/2">
|
||||
<h3 class="text-xl font-bold mb-4">User Management</h3>
|
||||
<p class="mb-4">SupaNuxt SaaS includes robust user management features, including
|
||||
authentication with social login (oauth) or email/password, management of user roles and permissions, and
|
||||
multi-user/team 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>
|
||||
<p class="mb-4">
|
||||
SupaNuxt SaaS includes robust user management features, including
|
||||
authentication with social login (oauth) or email/password,
|
||||
management of user roles and permissions, and multi-user/team
|
||||
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>
|
||||
<!-- DB Schema (text right)-->
|
||||
<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">
|
||||
<img 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">
|
||||
<img
|
||||
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 class="md:w-1/2">
|
||||
<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
|
||||
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
|
||||
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>
|
||||
<p class="mb-4">
|
||||
We use Prisma for schema management to make sure you can easily
|
||||
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 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>
|
||||
<!-- Config (text left) -->
|
||||
<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">
|
||||
<img 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">
|
||||
<img
|
||||
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 class="md:w-1/2">
|
||||
<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
|
||||
management that enables customisation and management of api keys.</p>
|
||||
<p class="mb-4">
|
||||
SupaNuxt SaaS includes an approach to config and environment
|
||||
management that enables customisation and management of api keys.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- State Management (text right)-->
|
||||
<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">
|
||||
<img 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">
|
||||
<img
|
||||
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 class="md:w-1/2">
|
||||
<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)
|
||||
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>
|
||||
<p class="mb-4">
|
||||
SupaNuxt SaaS includes multi modal state management that supports
|
||||
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>
|
||||
<!-- Stripe (text left) -->
|
||||
<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">
|
||||
<img 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">
|
||||
<img
|
||||
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 class="md:w-1/2">
|
||||
<h3 class="text-xl font-bold mb-4">Stripe Integration</h3>
|
||||
<p class="mb-4">SupaNuxt SaaS includes Stripe integration for subscription payments including
|
||||
Subscription based support for multi pricing and multiple plans.</p>
|
||||
<p class="mb-4">
|
||||
SupaNuxt SaaS includes Stripe integration for subscription
|
||||
payments including Subscription based support for multi pricing
|
||||
and multiple plans.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tailwind (text right)-->
|
||||
<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">
|
||||
<img 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">
|
||||
<img
|
||||
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 class="md:w-1/2">
|
||||
<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>
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
// this could probably be an elegant destructure here but I lost patience
|
||||
let account: AccountWithMembers | undefined;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -19,45 +21,51 @@
|
||||
async function doJoin() {
|
||||
if (account) {
|
||||
await accountStore.joinUserToAccountPending(account.id);
|
||||
navigateTo('/dashboard', {replace: true})
|
||||
navigateTo('/dashboard', { replace: true });
|
||||
} else {
|
||||
console.log(`Unable to Join`)
|
||||
console.log(`Unable to Join`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="py-10 px-4 sm:px-6 lg:px-8">
|
||||
<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">
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Click below to request to Join the team.
|
||||
Your request to join will remain as 'Pending'
|
||||
untill the team administrators complete their review.</p>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Click below to request to Join the team. Your request to join will
|
||||
remain as 'Pending' untill the team administrators complete their
|
||||
review.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
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
|
||||
the benefits of the team plan.
|
||||
will be able to switch to the team account at any time in order to
|
||||
share the benefits of the team plan.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<button @click.prevent="doJoin()" 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">
|
||||
<button
|
||||
@click.prevent="doJoin()"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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
|
||||
</button>
|
||||
<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
|
||||
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">
|
||||
<button
|
||||
@click.prevent="navigateTo('/signin')"
|
||||
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
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const { $client } = useNuxtApp();
|
||||
const { data: note } = await $client.notes.getById.useQuery({note_id: +route.params.note_id});
|
||||
const route = useRoute();
|
||||
const { $client } = useNuxtApp();
|
||||
const { data: note } = await $client.notes.getById.useQuery({
|
||||
note_id: +route.params.note_id
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="prose lg:prose-xl m-5">
|
||||
<h3>Note Detail {{ route.params.note_id }}</h3>
|
||||
<div class="prose lg:prose-xl m-5">{{ note?.note.note_text }}</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const accountStore = useAccountStore();
|
||||
const { activeMembership } = storeToRefs(accountStore);
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -10,39 +10,63 @@
|
||||
});
|
||||
</script>
|
||||
<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">
|
||||
<h2 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>
|
||||
<h2
|
||||
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 class="flex flex-col md:flex-row justify-center items-center">
|
||||
<!-- 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>
|
||||
<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
|
||||
v-if="!activeMembership"
|
||||
@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">
|
||||
Start for free
|
||||
v-if="!activeMembership"
|
||||
@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">
|
||||
Start for free
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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-->
|
||||
<form
|
||||
action="/create-checkout-session"
|
||||
method="POST"
|
||||
v-if="activeMembership && (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" />
|
||||
v-if="
|
||||
activeMembership &&
|
||||
(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
|
||||
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">
|
||||
@@ -51,26 +75,40 @@
|
||||
</form>
|
||||
<!-- anon user gets a link to sign up -->
|
||||
<button
|
||||
v-if="!activeMembership"
|
||||
@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">
|
||||
Get started
|
||||
v-if="!activeMembership"
|
||||
@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">
|
||||
Get started
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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-->
|
||||
<form
|
||||
action="/create-checkout-session"
|
||||
method="POST"
|
||||
v-if="activeMembership && (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" />
|
||||
v-if="
|
||||
activeMembership &&
|
||||
(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
|
||||
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">
|
||||
@@ -79,10 +117,10 @@
|
||||
</form>
|
||||
<!-- anon user gets a link to sign up -->
|
||||
<button
|
||||
v-if="!activeMembership"
|
||||
@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">
|
||||
Get started
|
||||
v-if="!activeMembership"
|
||||
@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">
|
||||
Get started
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,53 +2,83 @@
|
||||
<div class="prose lg:prose-xl m-5">
|
||||
<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
|
||||
use it. By using our website, you agree to the terms of this privacy statement.</p>
|
||||
<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>
|
||||
|
||||
<p>We collect personal information that you voluntarily provide to us when 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>
|
||||
We collect personal information that you voluntarily provide to us when
|
||||
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
|
||||
provider called Supabase to manage user authentication. When you use our website, Supabase may collect 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>
|
||||
<p>
|
||||
In addition to the personal data that we collect directly from you, we
|
||||
also use a third-party authentication provider called Supabase to manage
|
||||
user authentication. When you use our website, Supabase may collect
|
||||
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>
|
||||
|
||||
<p>We use your personal information to provide you with the products and 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>
|
||||
<p>
|
||||
We use your personal information to provide you with the products and
|
||||
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>
|
||||
|
||||
<p>We may disclose your personal information to third parties who provide 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>
|
||||
We may disclose your personal information to third parties who provide
|
||||
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.
|
||||
Supabase may share your personal 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>
|
||||
<p>
|
||||
As mentioned above, we use a third-party authentication provider called
|
||||
Supabase to manage user authentication. Supabase may share your personal
|
||||
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>
|
||||
|
||||
<p>We take reasonable measures to protect your personal information from 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>
|
||||
<p>
|
||||
We take reasonable measures to protect your personal information from
|
||||
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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<p>If you have any questions or concerns about our privacy practices, please contact us at [insert contact
|
||||
information].</p>
|
||||
|
||||
<p>
|
||||
If you have any questions or concerns about our privacy practices, please
|
||||
contact us at [insert contact information].
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,27 +3,27 @@
|
||||
|
||||
const notifyStore = useNotifyStore();
|
||||
|
||||
const loading = ref(false)
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const loading = ref(false);
|
||||
const password = ref('');
|
||||
const confirmPassword = ref('');
|
||||
|
||||
const changePassword = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
loading.value = true;
|
||||
const { data, error } = await supabase.auth.updateUser({
|
||||
password: password.value
|
||||
});
|
||||
if (error) throw error
|
||||
if (error) throw error;
|
||||
else {
|
||||
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
|
||||
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
|
||||
}
|
||||
} catch (error) {
|
||||
notifyStore.notify(error, NotificationType.Error);
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<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>
|
||||
<form @submit.prevent="changePassword" class="space-y-4">
|
||||
<div>
|
||||
<label for="password" class="block mb-2 font-bold">New Password</label>
|
||||
<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>
|
||||
<label for="password" class="block mb-2 font-bold"
|
||||
>New Password</label
|
||||
>
|
||||
<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>
|
||||
<label for="confirmPassword" class="block mb-2 font-bold">Confirm New Password</label>
|
||||
<input v-model="confirmPassword" id="confirmPassword" type="password" class="w-full p-2 border border-gray-400 rounded-md"
|
||||
placeholder="Confirm new password" required>
|
||||
<label for="confirmPassword" class="block mb-2 font-bold"
|
||||
>Confirm New Password</label
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,47 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
const user = useSupabaseUser()
|
||||
const user = useSupabaseUser();
|
||||
const supabase = useSupabaseAuthClient();
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
const accountStore = useAccountStore();
|
||||
const notifyStore = useNotifyStore();
|
||||
|
||||
const loading = ref(false)
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false);
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
|
||||
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 {
|
||||
loading.value = true
|
||||
const { error } = await supabase.auth.signInWithPassword({ email: email.value, password: password.value })
|
||||
if (error) throw error
|
||||
loading.value = true;
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email: email.value,
|
||||
password: password.value
|
||||
});
|
||||
if (error) throw error;
|
||||
} catch (error) {
|
||||
notifyStore.notify(error, NotificationType.Error);
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignin = async () => {
|
||||
console.log('handleGoogleSignin');
|
||||
try {
|
||||
loading.value = true
|
||||
const { error } = await supabase.auth.signInWithOAuth({ provider: 'google' })
|
||||
if (error) throw error
|
||||
loading.value = true;
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google'
|
||||
});
|
||||
if (error) throw error;
|
||||
} catch (error) {
|
||||
notifyStore.notify(error, NotificationType.Error);
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
watchEffect(async () => {
|
||||
if (user.value) {
|
||||
await accountStore.init();
|
||||
navigateTo('/dashboard', {replace: true})
|
||||
navigateTo('/dashboard', { replace: true });
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<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">
|
||||
<div>
|
||||
<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"
|
||||
placeholder="Enter your email" required>
|
||||
<input
|
||||
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>
|
||||
<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"
|
||||
placeholder="Enter your password" required>
|
||||
<input
|
||||
v-model="password"
|
||||
id="password"
|
||||
type="password"
|
||||
class="w-full p-2 border border-gray-400 rounded-md"
|
||||
placeholder="Enter your password"
|
||||
required />
|
||||
</div>
|
||||
<NuxtLink id="forgotPasswordLink" 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>
|
||||
<NuxtLink
|
||||
id="forgotPasswordLink"
|
||||
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>
|
||||
<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">
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
<Icon name="fa-brands:google" class="w-5 h-5" />
|
||||
|
||||
@@ -1,37 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
const user = useSupabaseUser()
|
||||
const user = useSupabaseUser();
|
||||
const supabase = useSupabaseAuthClient();
|
||||
|
||||
const notifyStore = useNotifyStore();
|
||||
|
||||
const loading = ref(false)
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const signUpOk = ref(false)
|
||||
const loading = ref(false);
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const signUpOk = ref(false);
|
||||
|
||||
const handleStandardSignup = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const { data, error } = await supabase.auth.signUp({ email: email.value, password: password.value })
|
||||
loading.value = true;
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: email.value,
|
||||
password: password.value
|
||||
});
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
else {
|
||||
throw error;
|
||||
} else {
|
||||
signUpOk.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
notifyStore.notify(error, NotificationType.Error);
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
if (user.value) {
|
||||
navigateTo('/dashboard', { replace: true })
|
||||
navigateTo('/dashboard', { replace: true });
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<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">
|
||||
<div>
|
||||
<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"
|
||||
placeholder="Enter your email" required>
|
||||
<input
|
||||
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>
|
||||
<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"
|
||||
placeholder="Enter your password" required>
|
||||
<input
|
||||
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>
|
||||
<label for="confirmPassword" class="block mb-2 font-bold">Confirm Password</label>
|
||||
<input v-model="confirmPassword" id="confirmPassword" type="password"
|
||||
class="w-full p-2 border border-gray-400 rounded-md" placeholder="Confirm your password" required>
|
||||
<label for="confirmPassword" class="block mb-2 font-bold"
|
||||
>Confirm Password</label
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
<Icon name="fa-brands:google" class="w-5 h-5" />
|
||||
@@ -67,8 +94,9 @@
|
||||
</span>
|
||||
</button>
|
||||
<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
|
||||
of Service</NuxtLink>
|
||||
By proceeding, I agree to the
|
||||
<NuxtLink to="/privacy">Privacy Statement</NuxtLink> and
|
||||
<NuxtLink to="/terms">Terms of Service</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import Stripe from 'stripe';
|
||||
const config = useRuntimeConfig();
|
||||
const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2022-11-15' });
|
||||
const route = useRoute();
|
||||
let customer: Stripe.Response<Stripe.Customer | Stripe.DeletedCustomer>
|
||||
try{
|
||||
const session = await stripe.checkout.sessions.retrieve(route?.query?.session_id as string);
|
||||
customer = await stripe.customers.retrieve(session?.customer as string);
|
||||
} catch(e) {
|
||||
console.log(`Error ${e}`)
|
||||
}
|
||||
import Stripe from 'stripe';
|
||||
const config = useRuntimeConfig();
|
||||
const stripe = new Stripe(config.stripeSecretKey, {
|
||||
apiVersion: '2022-11-15'
|
||||
});
|
||||
const route = useRoute();
|
||||
let customer: Stripe.Response<Stripe.Customer | Stripe.DeletedCustomer>;
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.retrieve(
|
||||
route?.query?.session_id as string
|
||||
);
|
||||
customer = await stripe.customers.retrieve(session?.customer as string);
|
||||
} catch (e) {
|
||||
console.log(`Error ${e}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose lg:prose-xl m-5">
|
||||
<p>
|
||||
<span v-if="customer && !customer.deleted">We appreciate your business {{customer.name}}!</span>
|
||||
<span v-if="customer && customer.deleted">It appears your stripe customer information has been deleted!</span>
|
||||
<span v-if="customer && !customer.deleted">
|
||||
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>Go to Your <NuxtLink to="/dashboard">Dashboard</NuxtLink></p>
|
||||
</div>
|
||||
|
||||
@@ -2,57 +2,95 @@
|
||||
<div class="prose lg:prose-xl m-5">
|
||||
<h1>Terms of Service</h1>
|
||||
|
||||
<p>These terms of service (the "Agreement") govern your use of our website 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>
|
||||
<p>
|
||||
These terms of service (the "Agreement") govern your use of our website
|
||||
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>
|
||||
|
||||
<p>You may use the Service only for lawful purposes and in accordance with this Agreement. You agree not to use the
|
||||
Service:</p>
|
||||
<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>
|
||||
<li>In any way that violates any applicable federal, state, local, or international law or regulation</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>
|
||||
<li>
|
||||
In any way that violates any applicable federal, state, local, or
|
||||
international law or regulation
|
||||
</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>
|
||||
|
||||
<h2>Intellectual Property</h2>
|
||||
|
||||
<p>The Service and its entire contents, features, and functionality (including but not limited to all information,
|
||||
software, text, displays, 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>
|
||||
The Service and its entire contents, features, and functionality
|
||||
(including but not limited to all information, software, text, displays,
|
||||
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,
|
||||
republish, download, store, or transmit any of the material on our website, except as follows:</p>
|
||||
<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>
|
||||
<li>Your computer may temporarily store copies of such materials in RAM incidental to your accessing and viewing
|
||||
those materials</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>
|
||||
<li>
|
||||
Your computer may temporarily store copies of such materials in RAM
|
||||
incidental to your accessing and viewing those materials
|
||||
</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>
|
||||
|
||||
<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
|
||||
express or implied, including but not 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>
|
||||
<p>
|
||||
The Service is provided on an "as is" and "as available" basis, without
|
||||
any warranties of any kind, either express or implied, including but not
|
||||
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>
|
||||
|
||||
<p>In no event shall we be liable for any direct, indirect, incidental, 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>
|
||||
<p>
|
||||
In no event shall we be liable for any direct, indirect, incidental,
|
||||
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>
|
||||
|
||||
<p>You agree to defend, indemnify, and hold us harmless from and against any claims, liabilities, damages,
|
||||
judgments, fines, costs, and expenses.</p>
|
||||
|
||||
<p>
|
||||
You agree to defend, indemnify, and hold us harmless from and against any
|
||||
claims, liabilities, damages, judgments, fines, costs, and expenses.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import "vanilla-cookieconsent/dist/cookieconsent.css";
|
||||
import "vanilla-cookieconsent/src/cookieconsent.js";
|
||||
import 'vanilla-cookieconsent/dist/cookieconsent.css';
|
||||
import 'vanilla-cookieconsent/src/cookieconsent.js';
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
export default defineNuxtPlugin(nuxtApp => {
|
||||
// @ts-ignore
|
||||
const cookieConsent = window.initCookieConsent();
|
||||
|
||||
cookieConsent.run({
|
||||
current_lang: "en",
|
||||
current_lang: 'en',
|
||||
autoclear_cookies: true, // default: false
|
||||
page_scripts: true, // default: false
|
||||
|
||||
@@ -41,91 +41,91 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
languages: {
|
||||
en: {
|
||||
consent_modal: {
|
||||
title: "We use cookies!",
|
||||
title: 'We use cookies!',
|
||||
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>',
|
||||
primary_btn: {
|
||||
text: "Accept all",
|
||||
role: "accept_all", // 'accept_selected' or 'accept_all'
|
||||
text: 'Accept all',
|
||||
role: 'accept_all' // 'accept_selected' or 'accept_all'
|
||||
},
|
||||
secondary_btn: {
|
||||
text: "Reject all",
|
||||
role: "accept_necessary", // 'settings' or 'accept_necessary'
|
||||
},
|
||||
text: 'Reject all',
|
||||
role: 'accept_necessary' // 'settings' or 'accept_necessary'
|
||||
}
|
||||
},
|
||||
settings_modal: {
|
||||
title: "Cookie preferences",
|
||||
save_settings_btn: "Save settings",
|
||||
accept_all_btn: "Accept all",
|
||||
reject_all_btn: "Reject all",
|
||||
close_btn_label: "Close",
|
||||
title: 'Cookie preferences',
|
||||
save_settings_btn: 'Save settings',
|
||||
accept_all_btn: 'Accept all',
|
||||
reject_all_btn: 'Reject all',
|
||||
close_btn_label: 'Close',
|
||||
// cookie_table_caption: 'Cookie list',
|
||||
cookie_table_headers: [
|
||||
{ col1: "Name" },
|
||||
{ col2: "Domain" },
|
||||
{ col3: "Expiration" },
|
||||
{ col4: "Description" },
|
||||
{ col1: 'Name' },
|
||||
{ col2: 'Domain' },
|
||||
{ col3: 'Expiration' },
|
||||
{ col4: 'Description' }
|
||||
],
|
||||
blocks: [
|
||||
{
|
||||
title: "Cookie usage 📢",
|
||||
title: 'Cookie usage 📢',
|
||||
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:
|
||||
"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: {
|
||||
value: "necessary",
|
||||
value: 'necessary',
|
||||
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:
|
||||
"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: {
|
||||
value: "analytics", // your cookie category
|
||||
value: 'analytics', // your cookie category
|
||||
enabled: false,
|
||||
readonly: false,
|
||||
readonly: false
|
||||
},
|
||||
cookie_table: [
|
||||
// list of all expected cookies
|
||||
{
|
||||
col1: "^_ga", // match all cookies starting with "_ga"
|
||||
col2: "google.com",
|
||||
col3: "2 years",
|
||||
col4: "description ...",
|
||||
is_regex: true,
|
||||
col1: '^_ga', // match all cookies starting with "_ga"
|
||||
col2: 'google.com',
|
||||
col3: '2 years',
|
||||
col4: 'description ...',
|
||||
is_regex: true
|
||||
},
|
||||
{
|
||||
col1: "_gid",
|
||||
col2: "google.com",
|
||||
col3: "1 day",
|
||||
col4: "description ...",
|
||||
},
|
||||
],
|
||||
col1: '_gid',
|
||||
col2: 'google.com',
|
||||
col3: '1 day',
|
||||
col4: 'description ...'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Advertisement and Targeting cookies",
|
||||
title: 'Advertisement and Targeting cookies',
|
||||
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: {
|
||||
value: "targeting",
|
||||
value: 'targeting',
|
||||
enabled: false,
|
||||
readonly: false,
|
||||
},
|
||||
readonly: false
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "More information",
|
||||
title: 'More information',
|
||||
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>.'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTRPCNuxtClient, httpBatchLink } from "trpc-nuxt/client";
|
||||
import type { AppRouter } from "~/server/trpc/routers/app.router";
|
||||
import superjson from "superjson";
|
||||
import { createTRPCNuxtClient, httpBatchLink } from 'trpc-nuxt/client';
|
||||
import type { AppRouter } from '~/server/trpc/routers/app.router';
|
||||
import superjson from 'superjson';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
/**
|
||||
@@ -10,15 +10,15 @@ export default defineNuxtPlugin(() => {
|
||||
const client = createTRPCNuxtClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: "/api/trpc",
|
||||
}),
|
||||
url: '/api/trpc'
|
||||
})
|
||||
],
|
||||
transformer: superjson,
|
||||
transformer: superjson
|
||||
});
|
||||
|
||||
return {
|
||||
provide: {
|
||||
client,
|
||||
},
|
||||
client
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// Workaround for prisma issue (https://github.com/prisma/prisma/issues/12504#issuecomment-1147356141)
|
||||
|
||||
// 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
|
||||
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_WRITE: 'READ_WRITE',
|
||||
ADMIN: 'ADMIN',
|
||||
OWNER: 'OWNER'
|
||||
} as const
|
||||
} as const;
|
||||
|
||||
// Re-exporting the original type with the original name
|
||||
export type ACCOUNT_ACCESS = ACCOUNT_ACCESS_ORIGINAL
|
||||
export type ACCOUNT_ACCESS = ACCOUNT_ACCESS_ORIGINAL;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import pkg from "@prisma/client";
|
||||
import pkg from '@prisma/client';
|
||||
|
||||
const { PrismaClient } = pkg;
|
||||
const prisma_client = new PrismaClient()
|
||||
export default prisma_client
|
||||
const prisma_client = new PrismaClient();
|
||||
export default prisma_client;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
const prisma = new PrismaClient()
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
async function main() {
|
||||
const freeTrial = await prisma.plan.upsert({
|
||||
where: { name: 'Free Trial' },
|
||||
@@ -9,8 +9,8 @@ async function main() {
|
||||
features: ['ADD_NOTES', 'EDIT_NOTES', 'VIEW_NOTES'],
|
||||
max_notes: 10,
|
||||
max_members: 1,
|
||||
ai_gen_max_pm: 7,
|
||||
},
|
||||
ai_gen_max_pm: 7
|
||||
}
|
||||
});
|
||||
const individualPlan = await prisma.plan.upsert({
|
||||
where: { name: 'Individual Plan' },
|
||||
@@ -22,29 +22,35 @@ async function main() {
|
||||
max_members: 1,
|
||||
ai_gen_max_pm: 50,
|
||||
stripe_product_id: 'prod_NQR7vwUulvIeqW'
|
||||
},
|
||||
}
|
||||
});
|
||||
const teamPlan = await prisma.plan.upsert({
|
||||
where: { name: 'Team Plan' },
|
||||
update: {},
|
||||
create: {
|
||||
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_members: 10,
|
||||
ai_gen_max_pm: 500,
|
||||
stripe_product_id: 'prod_NQR8IkkdhqBwu2'
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
console.log({ freeTrial, individualPlan, teamPlan })
|
||||
console.log({ freeTrial, individualPlan, teamPlan });
|
||||
}
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async e => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -4,10 +4,10 @@ import NotesService from '~/lib/services/notes.service';
|
||||
|
||||
// Example API Route with query params ... /api/note?note_id=41
|
||||
export default defineProtectedEventHandler(async (event: H3Event) => {
|
||||
const queryParams = getQuery(event)
|
||||
const queryParams = getQuery(event);
|
||||
let note_id: string = '';
|
||||
if(queryParams.note_id){
|
||||
if (Array.isArray( queryParams.note_id)) {
|
||||
if (queryParams.note_id) {
|
||||
if (Array.isArray(queryParams.note_id)) {
|
||||
note_id = queryParams.note_id[0];
|
||||
} else {
|
||||
note_id = queryParams.note_id.toString();
|
||||
@@ -18,6 +18,6 @@ export default defineProtectedEventHandler(async (event: H3Event) => {
|
||||
const note = await notesService.getNoteById(+note_id);
|
||||
|
||||
return {
|
||||
note,
|
||||
}
|
||||
})
|
||||
note
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* 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.
|
||||
*/
|
||||
import { createNuxtApiHandler } from "trpc-nuxt";
|
||||
import { createContext } from "~~/server/trpc/context";
|
||||
import { appRouter } from "~~/server/trpc/routers/app.router";
|
||||
import { createNuxtApiHandler } from 'trpc-nuxt';
|
||||
import { createContext } from '~~/server/trpc/context';
|
||||
import { appRouter } from '~~/server/trpc/routers/app.router';
|
||||
|
||||
// export API handler
|
||||
export default createNuxtApiHandler({
|
||||
@@ -12,5 +12,5 @@ export default createNuxtApiHandler({
|
||||
createContext: createContext,
|
||||
onError({ error }) {
|
||||
console.error(error);
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EventHandler, EventHandlerRequest, H3Event, eventHandler } from "h3";
|
||||
import { EventHandler, EventHandlerRequest, H3Event, eventHandler } from 'h3';
|
||||
|
||||
export const defineProtectedEventHandler = <T extends EventHandlerRequest>(
|
||||
handler: EventHandler<T>
|
||||
@@ -8,7 +8,7 @@ export const defineProtectedEventHandler = <T extends EventHandlerRequest>(
|
||||
return eventHandler((event: H3Event) => {
|
||||
const user = event.context.user;
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthenticated" });
|
||||
throw createError({ statusCode: 401, statusMessage: 'Unauthenticated' });
|
||||
}
|
||||
return handler(event);
|
||||
});
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { defineEventHandler, parseCookies, setCookie, getCookie } from 'h3'
|
||||
import { serverSupabaseUser } from "#supabase/server";
|
||||
import { defineEventHandler, parseCookies, setCookie, getCookie } from 'h3';
|
||||
import { serverSupabaseUser } from '#supabase/server';
|
||||
import AuthService from '~/lib/services/auth.service';
|
||||
|
||||
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)
|
||||
declare module 'h3' {
|
||||
interface H3EventContext {
|
||||
user?: User; // the Supabase User
|
||||
dbUser?: FullDBUser; // the corresponding Database User
|
||||
user?: User; // the Supabase User
|
||||
dbUser?: FullDBUser; // the corresponding Database User
|
||||
activeAccountId?: number; // the account ID that is active for the user
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const cookies = parseCookies(event)
|
||||
if(cookies && cookies['sb-access-token']){
|
||||
export default defineEventHandler(async event => {
|
||||
const cookies = parseCookies(event);
|
||||
if (cookies && cookies['sb-access-token']) {
|
||||
const user = await serverSupabaseUser(event);
|
||||
if (user) {
|
||||
event.context.user = user;
|
||||
@@ -25,22 +25,38 @@ export default defineEventHandler(async (event) => {
|
||||
let dbUser = await authService.getFullUserBySupabaseId(user.id);
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
if(dbUser){
|
||||
if (dbUser) {
|
||||
event.context.dbUser = dbUser;
|
||||
let activeAccountId;
|
||||
const preferredAccountId = getCookie(event, 'preferred-active-account-id')
|
||||
if(preferredAccountId && dbUser?.memberships.find(m => m.account_id === +preferredAccountId && !m.pending)){
|
||||
activeAccountId = +preferredAccountId
|
||||
const preferredAccountId = getCookie(
|
||||
event,
|
||||
'preferred-active-account-id'
|
||||
);
|
||||
if (
|
||||
preferredAccountId &&
|
||||
dbUser?.memberships.find(
|
||||
m => m.account_id === +preferredAccountId && !m.pending
|
||||
)
|
||||
) {
|
||||
activeAccountId = +preferredAccountId;
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
if(activeAccountId){
|
||||
if (activeAccountId) {
|
||||
event.context.activeAccountId = activeAccountId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,31 @@ import { AccountWithMembers } from '~~/lib/services/service.types';
|
||||
const config = useRuntimeConfig();
|
||||
const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2022-11-15' });
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
let { price_id, account_id} = body;
|
||||
account_id = +account_id
|
||||
console.log(`session.post.ts recieved price_id:${price_id}, account_id:${account_id}`);
|
||||
export default defineEventHandler(async event => {
|
||||
const body = await readBody(event);
|
||||
let { price_id, account_id } = body;
|
||||
account_id = +account_id;
|
||||
console.log(
|
||||
`session.post.ts recieved price_id:${price_id}, account_id:${account_id}`
|
||||
);
|
||||
|
||||
const accountService = new AccountService();
|
||||
const account: AccountWithMembers = await accountService.getAccountById(account_id);
|
||||
let customer_id: string
|
||||
if(!account.stripe_customer_id){
|
||||
const account: AccountWithMembers = await accountService.getAccountById(
|
||||
account_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
|
||||
const owner = account.members.find(member => (member.access == ACCOUNT_ACCESS.OWNER))
|
||||
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 });
|
||||
const owner = account.members.find(
|
||||
member => member.access == ACCOUNT_ACCESS.OWNER
|
||||
);
|
||||
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;
|
||||
accountService.updateAccountStipeCustomerId(account_id, customer.id);
|
||||
} else {
|
||||
@@ -31,8 +42,8 @@ export default defineEventHandler(async (event) => {
|
||||
line_items: [
|
||||
{
|
||||
price: price_id,
|
||||
quantity: 1,
|
||||
},
|
||||
quantity: 1
|
||||
}
|
||||
],
|
||||
// {CHECKOUT_SESSION_ID} is a string literal; do not change it!
|
||||
// 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
|
||||
});
|
||||
|
||||
if(session?.url){
|
||||
if (session?.url) {
|
||||
return sendRedirect(event, session.url, 303);
|
||||
} else {
|
||||
return sendRedirect(event, `${config.public.siteRootUrl}/fail`, 303);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -4,45 +4,76 @@ import AccountService from '~~/lib/services/account.service';
|
||||
const config = useRuntimeConfig();
|
||||
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');
|
||||
if(!stripeSignature){
|
||||
throw createError({ statusCode: 400, statusMessage: 'Webhook Error: No stripe signature in header' });
|
||||
if (!stripeSignature) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Webhook Error: No stripe signature in header'
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await readRawBody(event)
|
||||
if(!rawBody){
|
||||
throw createError({ statusCode: 400, statusMessage: 'Webhook Error: No body' });
|
||||
const rawBody = await readRawBody(event);
|
||||
if (!rawBody) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Webhook Error: No body'
|
||||
});
|
||||
}
|
||||
let stripeEvent: Stripe.Event;
|
||||
|
||||
try {
|
||||
stripeEvent = stripe.webhooks.constructEvent(rawBody, stripeSignature, config.stripeEndpointSecret);
|
||||
}
|
||||
catch (err) {
|
||||
stripeEvent = stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
stripeSignature,
|
||||
config.stripeEndpointSecret
|
||||
);
|
||||
} catch (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}) ******`);
|
||||
|
||||
let subscription = stripeEvent.data.object as Stripe.Subscription;
|
||||
if(subscription.status == 'active'){
|
||||
const sub_item = subscription.items.data.find(item => item?.object && item?.object == 'subscription_item')
|
||||
let subscription = stripeEvent.data.object as Stripe.Subscription;
|
||||
if (subscription.status == 'active') {
|
||||
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?
|
||||
if(!stripe_product_id){
|
||||
throw createError({ statusCode: 400, statusMessage: `Error validating Webhook Event` });
|
||||
if (!stripe_product_id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: `Error validating Webhook Event`
|
||||
});
|
||||
}
|
||||
|
||||
const accountService = new AccountService();
|
||||
|
||||
let current_period_ends: Date = new Date(subscription.current_period_end * 1000);
|
||||
current_period_ends.setDate(current_period_ends.getDate() + config.subscriptionGraceDays);
|
||||
let current_period_ends: Date = new Date(
|
||||
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}`);
|
||||
accountService.updateStripeSubscriptionDetailsForAccount(subscription.customer.toString(), subscription.id, current_period_ends, stripe_product_id);
|
||||
console.log(
|
||||
`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}.`;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { inferAsyncReturnType } from '@trpc/server'
|
||||
import { inferAsyncReturnType } from '@trpc/server';
|
||||
import { H3Event } from 'h3';
|
||||
|
||||
export async function createContext(event: H3Event){
|
||||
export async function createContext(event: H3Event) {
|
||||
return {
|
||||
user: event.context.user, // the Supabase User
|
||||
dbUser: event.context.dbUser, // the corresponding Database User
|
||||
user: event.context.user, // the Supabase User
|
||||
dbUser: event.context.dbUser, // the corresponding Database 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>;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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 { z } from 'zod';
|
||||
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
|
||||
*/
|
||||
export const accountRouter = router({
|
||||
getDBUser: publicProcedure
|
||||
.query(({ ctx }) => {
|
||||
return {
|
||||
dbUser: ctx.dbUser,
|
||||
}
|
||||
}),
|
||||
getActiveAccountId: publicProcedure
|
||||
.query(({ ctx }) => {
|
||||
return {
|
||||
activeAccountId: ctx.activeAccountId,
|
||||
}
|
||||
}),
|
||||
getDBUser: publicProcedure.query(({ ctx }) => {
|
||||
return {
|
||||
dbUser: ctx.dbUser
|
||||
};
|
||||
}),
|
||||
getActiveAccountId: publicProcedure.query(({ ctx }) => {
|
||||
return {
|
||||
activeAccountId: ctx.activeAccountId
|
||||
};
|
||||
}),
|
||||
changeActiveAccount: protectedProcedure
|
||||
.input(z.object({ account_id: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const activeMembership = ctx.dbUser?.memberships.find(membership => membership.account_id == input.account_id);
|
||||
if(activeMembership?.pending){
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message:`membership ${activeMembership?.id} is not active so cannot be switched to` });
|
||||
const activeMembership = ctx.dbUser?.memberships.find(
|
||||
membership => membership.account_id == input.account_id
|
||||
);
|
||||
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;
|
||||
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
|
||||
.input(z.object({ new_name: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
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 {
|
||||
account,
|
||||
}
|
||||
}),
|
||||
rotateJoinPassword: adminProcedure
|
||||
.mutation(async ({ ctx }) => {
|
||||
const accountService = new AccountService();
|
||||
const account = await accountService.rotateJoinPassword(ctx.activeAccountId!);
|
||||
return {
|
||||
account,
|
||||
}
|
||||
account
|
||||
};
|
||||
}),
|
||||
rotateJoinPassword: adminProcedure.mutation(async ({ ctx }) => {
|
||||
const accountService = new AccountService();
|
||||
const account = await accountService.rotateJoinPassword(
|
||||
ctx.activeAccountId!
|
||||
);
|
||||
return {
|
||||
account
|
||||
};
|
||||
}),
|
||||
getAccountByJoinPassword: publicProcedure
|
||||
.input(z.object({ join_password: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
const accountService = new AccountService();
|
||||
const account = await accountService.getAccountByJoinPassword(input.join_password);
|
||||
const account = await accountService.getAccountByJoinPassword(
|
||||
input.join_password
|
||||
);
|
||||
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
|
||||
.input(z.object({ account_id: z.number(), user_id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
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 {
|
||||
membership,
|
||||
}
|
||||
membership
|
||||
};
|
||||
}),
|
||||
acceptPendingMembership: adminProcedure
|
||||
.input(z.object({ membership_id: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
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 {
|
||||
membership,
|
||||
}
|
||||
membership
|
||||
};
|
||||
}),
|
||||
rejectPendingMembership: adminProcedure
|
||||
.input(z.object({ membership_id: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
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 {
|
||||
membership,
|
||||
}
|
||||
membership
|
||||
};
|
||||
}),
|
||||
deleteMembership: ownerProcedure
|
||||
.input(z.object({ membership_id: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
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 {
|
||||
membership,
|
||||
}
|
||||
membership
|
||||
};
|
||||
}),
|
||||
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 }) => {
|
||||
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 {
|
||||
membership,
|
||||
}
|
||||
membership
|
||||
};
|
||||
}),
|
||||
claimOwnershipOfAccount: adminProcedure
|
||||
.mutation(async ({ ctx }) => {
|
||||
const accountService = new AccountService();
|
||||
const memberships = await accountService.claimOwnershipOfAccount(ctx.dbUser!.id, ctx.activeAccountId!);
|
||||
return {
|
||||
memberships,
|
||||
}
|
||||
}),
|
||||
getAccountMembers: adminProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
const accountService = new AccountService();
|
||||
const memberships = await accountService.getAccountMembers(ctx.activeAccountId!);
|
||||
return {
|
||||
memberships,
|
||||
}
|
||||
}),
|
||||
})
|
||||
claimOwnershipOfAccount: adminProcedure.mutation(async ({ ctx }) => {
|
||||
const accountService = new AccountService();
|
||||
const memberships = await accountService.claimOwnershipOfAccount(
|
||||
ctx.dbUser!.id,
|
||||
ctx.activeAccountId!
|
||||
);
|
||||
return {
|
||||
memberships
|
||||
};
|
||||
}),
|
||||
getAccountMembers: adminProcedure.query(async ({ ctx }) => {
|
||||
const accountService = new AccountService();
|
||||
const memberships = await accountService.getAccountMembers(
|
||||
ctx.activeAccountId!
|
||||
);
|
||||
return {
|
||||
memberships
|
||||
};
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { router } from "~/server/trpc/trpc";
|
||||
import { notesRouter } from "./notes.router";
|
||||
import { authRouter } from "./auth.router";
|
||||
import { accountRouter } from "./account.router";
|
||||
import { router } from '~/server/trpc/trpc';
|
||||
import { notesRouter } from './notes.router';
|
||||
import { authRouter } from './auth.router';
|
||||
import { accountRouter } from './account.router';
|
||||
|
||||
export const appRouter = router({
|
||||
notes: notesRouter,
|
||||
auth: authRouter,
|
||||
account: accountRouter,
|
||||
account: accountRouter
|
||||
});
|
||||
|
||||
// export only the type definition of the API
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { publicProcedure, router } from '../trpc'
|
||||
import { publicProcedure, router } from '../trpc';
|
||||
|
||||
export const authRouter = router({
|
||||
getDBUser: publicProcedure
|
||||
.query(({ ctx }) => {
|
||||
return {
|
||||
dbUser: ctx.dbUser,
|
||||
}
|
||||
}),
|
||||
})
|
||||
getDBUser: publicProcedure.query(({ ctx }) => {
|
||||
return {
|
||||
dbUser: ctx.dbUser
|
||||
};
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,50 +1,68 @@
|
||||
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';
|
||||
|
||||
export const notesRouter = router({
|
||||
getForActiveAccount: memberProcedure
|
||||
.query(async ({ ctx, input }) => {
|
||||
const notesService = new NotesService();
|
||||
const notes = (ctx.activeAccountId)?await notesService.getNotesForAccountId(ctx.activeAccountId):[];
|
||||
return {
|
||||
notes,
|
||||
}
|
||||
}),
|
||||
getForActiveAccount: memberProcedure.query(async ({ ctx, input }) => {
|
||||
const notesService = new NotesService();
|
||||
const notes = ctx.activeAccountId
|
||||
? await notesService.getNotesForAccountId(ctx.activeAccountId)
|
||||
: [];
|
||||
return {
|
||||
notes
|
||||
};
|
||||
}),
|
||||
getById: publicProcedure
|
||||
.input(z.object({ note_id: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const notesService = new NotesService();
|
||||
const note = await notesService.getNoteById(input.note_id);
|
||||
return {
|
||||
note,
|
||||
}
|
||||
note
|
||||
};
|
||||
}),
|
||||
createNote: readWriteProcedure
|
||||
.input(z.object({ note_text: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
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 {
|
||||
note,
|
||||
}
|
||||
note
|
||||
};
|
||||
}),
|
||||
deleteNote: adminProcedure
|
||||
.input(z.object({ note_id: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
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 {
|
||||
note,
|
||||
}
|
||||
note
|
||||
};
|
||||
}),
|
||||
generateAINoteFromPrompt: readWriteProcedure.use(accountHasSpecialFeature)
|
||||
generateAINoteFromPrompt: readWriteProcedure
|
||||
.use(accountHasSpecialFeature)
|
||||
.input(z.object({ user_prompt: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
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 {
|
||||
noteText
|
||||
}
|
||||
}),
|
||||
})
|
||||
};
|
||||
})
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @see https://trpc.io/docs/v10/router
|
||||
* @see https://trpc.io/docs/v10/procedures
|
||||
*/
|
||||
import { initTRPC, TRPCError } from '@trpc/server'
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
import { Context } from './context';
|
||||
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
|
||||
import superjson from 'superjson';
|
||||
@@ -15,7 +15,7 @@ import { AccountLimitError } from '~~/lib/services/errors';
|
||||
|
||||
const t = initTRPC.context<Context>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter: (opts)=> {
|
||||
errorFormatter: opts => {
|
||||
const { shape, error } = opts;
|
||||
if (!(error.cause instanceof AccountLimitError)) {
|
||||
return shape;
|
||||
@@ -26,10 +26,10 @@ const t = initTRPC.context<Context>().create({
|
||||
...shape.data,
|
||||
httpStatus: 401,
|
||||
code: 'UNAUTHORIZED'
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* auth middlewares
|
||||
@@ -40,58 +40,97 @@ const isAuthed = t.middleware(({ next, ctx }) => {
|
||||
}
|
||||
return next({
|
||||
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 }) => {
|
||||
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);
|
||||
console.log(
|
||||
`isMemberWithAccessesForActiveAccountId(${access}) activeMembership?.access:${activeMembership?.access}`
|
||||
);
|
||||
|
||||
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) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message:`user is not a member of the active account` });
|
||||
}
|
||||
if (activeMembership.pending) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: `membership ${activeMembership?.id} is pending approval`
|
||||
});
|
||||
}
|
||||
|
||||
if(activeMembership.pending) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message:`membership ${activeMembership?.id} is pending approval` });
|
||||
}
|
||||
if (access.length > 0 && !access.includes(activeMembership.access)) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: `activeMembership ${activeMembership?.id} has insufficient access (${activeMembership?.access})`
|
||||
});
|
||||
}
|
||||
|
||||
if(access.length > 0 && !access.includes(activeMembership.access)) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message:`activeMembership ${activeMembership?.id} has insufficient access (${activeMembership?.access})` });
|
||||
}
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
if (!ctx.dbUser || !ctx.activeAccountId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const activeMembership = ctx.dbUser.memberships.find(membership => membership.account_id == ctx.activeAccountId);
|
||||
console.log(
|
||||
`isAccountWithFeature(${feature}) activeMembership?.account.features:${activeMembership?.account.features}`
|
||||
);
|
||||
if (!activeMembership?.account.features.includes(feature)) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: `Account does not have the ${feature} feature`
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`isAccountWithFeature(${feature}) activeMembership?.account.features:${activeMembership?.account.features}`);
|
||||
if(!activeMembership?.account.features.includes(feature)){
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: `Account does not have the ${feature} feature` });
|
||||
}
|
||||
|
||||
return next({ ctx });
|
||||
});
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
/**
|
||||
* Procedures
|
||||
**/
|
||||
export const publicProcedure = t.procedure;
|
||||
export const protectedProcedure = t.procedure.use(isAuthed);
|
||||
export const memberProcedure = protectedProcedure.use(isMemberWithAccessesForActiveAccountId([]));
|
||||
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 memberProcedure = protectedProcedure.use(
|
||||
isMemberWithAccessesForActiveAccountId([])
|
||||
);
|
||||
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 router = t.router;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ACCOUNT_ACCESS } from '~~/prisma/account-access-enum';
|
||||
import { defineStore } from "pinia"
|
||||
import { FullDBUser, MembershipWithUser } from "~~/lib/services/service.types";
|
||||
import { defineStore } from 'pinia';
|
||||
import { FullDBUser, MembershipWithUser } from '~~/lib/services/service.types';
|
||||
|
||||
/*
|
||||
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*
|
||||
*/
|
||||
interface State {
|
||||
dbUser: FullDBUser | null,
|
||||
activeAccountId: number | null,
|
||||
activeAccountMembers: MembershipWithUser[]
|
||||
dbUser: FullDBUser | null;
|
||||
activeAccountId: number | null;
|
||||
activeAccountMembers: MembershipWithUser[];
|
||||
}
|
||||
|
||||
export const useAccountStore = defineStore('account', {
|
||||
@@ -33,117 +33,155 @@ export const useAccountStore = defineStore('account', {
|
||||
return {
|
||||
dbUser: null,
|
||||
activeAccountId: null,
|
||||
activeAccountMembers: [],
|
||||
}
|
||||
activeAccountMembers: []
|
||||
};
|
||||
},
|
||||
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: {
|
||||
async init(){
|
||||
async init() {
|
||||
const { $client } = useNuxtApp();
|
||||
if(!this.dbUser){
|
||||
if (!this.dbUser) {
|
||||
const { dbUser } = await $client.auth.getDBUser.query();
|
||||
if(dbUser){
|
||||
if (dbUser) {
|
||||
this.dbUser = dbUser;
|
||||
}
|
||||
}
|
||||
if(!this.activeAccountId){
|
||||
const { activeAccountId } = await $client.account.getActiveAccountId.query();
|
||||
if(activeAccountId){
|
||||
if (!this.activeAccountId) {
|
||||
const { activeAccountId } =
|
||||
await $client.account.getActiveAccountId.query();
|
||||
if (activeAccountId) {
|
||||
this.activeAccountId = activeAccountId;
|
||||
}
|
||||
}
|
||||
},
|
||||
signout(){
|
||||
signout() {
|
||||
this.dbUser = null;
|
||||
this.activeAccountId = null;
|
||||
this.activeAccountMembers = [];
|
||||
},
|
||||
async getActiveAccountMembers(){
|
||||
if(this.activeMembership && (this.activeMembership.access === ACCOUNT_ACCESS.ADMIN || this.activeMembership.access === ACCOUNT_ACCESS.OWNER)){
|
||||
async getActiveAccountMembers() {
|
||||
if (
|
||||
this.activeMembership &&
|
||||
(this.activeMembership.access === ACCOUNT_ACCESS.ADMIN ||
|
||||
this.activeMembership.access === ACCOUNT_ACCESS.OWNER)
|
||||
) {
|
||||
const { $client } = useNuxtApp();
|
||||
const { data: memberships } = await $client.account.getAccountMembers.useQuery();
|
||||
if(memberships.value?.memberships){
|
||||
const { data: memberships } =
|
||||
await $client.account.getAccountMembers.useQuery();
|
||||
if (memberships.value?.memberships) {
|
||||
this.activeAccountMembers = memberships.value?.memberships;
|
||||
}
|
||||
}
|
||||
},
|
||||
async changeActiveAccount(account_id: number){
|
||||
async changeActiveAccount(account_id: number) {
|
||||
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
|
||||
},
|
||||
async changeAccountName(new_name: string){
|
||||
if(!this.activeMembership){ return; }
|
||||
async changeAccountName(new_name: string) {
|
||||
if (!this.activeMembership) {
|
||||
return;
|
||||
}
|
||||
const { $client } = useNuxtApp();
|
||||
const { account } = await $client.account.changeAccountName.mutate({ new_name });
|
||||
if(account){
|
||||
const { account } = await $client.account.changeAccountName.mutate({
|
||||
new_name
|
||||
});
|
||||
if (account) {
|
||||
this.activeMembership.account.name = account.name;
|
||||
}
|
||||
},
|
||||
async acceptPendingMembership(membership_id: number){
|
||||
async acceptPendingMembership(membership_id: number) {
|
||||
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){
|
||||
for(const m of this.activeAccountMembers){
|
||||
if(m.id === membership_id){
|
||||
if (membership.value && membership.value.membership?.pending === false) {
|
||||
for (const m of this.activeAccountMembers) {
|
||||
if (m.id === membership_id) {
|
||||
m.pending = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async rejectPendingMembership(membership_id: number){
|
||||
async rejectPendingMembership(membership_id: number) {
|
||||
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){
|
||||
this.activeAccountMembers = this.activeAccountMembers.filter(m => m.id !== membership_id);
|
||||
if (membership.value) {
|
||||
this.activeAccountMembers = this.activeAccountMembers.filter(
|
||||
m => m.id !== membership_id
|
||||
);
|
||||
}
|
||||
},
|
||||
async deleteMembership(membership_id: number){
|
||||
async deleteMembership(membership_id: number) {
|
||||
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){
|
||||
this.activeAccountMembers = this.activeAccountMembers.filter(m => m.id !== membership_id);
|
||||
if (membership.value) {
|
||||
this.activeAccountMembers = this.activeAccountMembers.filter(
|
||||
m => m.id !== membership_id
|
||||
);
|
||||
}
|
||||
},
|
||||
async rotateJoinPassword(){
|
||||
async rotateJoinPassword() {
|
||||
const { $client } = useNuxtApp();
|
||||
const { account } = await $client.account.rotateJoinPassword.mutate();
|
||||
if(account && this.activeMembership){
|
||||
if (account && this.activeMembership) {
|
||||
this.activeMembership.account = account;
|
||||
}
|
||||
},
|
||||
async joinUserToAccountPending(account_id: number){
|
||||
if(!this.dbUser) { return; }
|
||||
async joinUserToAccountPending(account_id: number) {
|
||||
if (!this.dbUser) {
|
||||
return;
|
||||
}
|
||||
const { $client } = useNuxtApp();
|
||||
const { membership } = await $client.account.joinUserToAccountPending.mutate({account_id, user_id: this.dbUser.id});
|
||||
if(membership && this.activeMembership){
|
||||
const { membership } =
|
||||
await $client.account.joinUserToAccountPending.mutate({
|
||||
account_id,
|
||||
user_id: this.dbUser.id
|
||||
});
|
||||
if (membership && this.activeMembership) {
|
||||
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 { membership } = await $client.account.changeUserAccessWithinAccount.mutate({ user_id, access });
|
||||
if(membership){
|
||||
for(const m of this.activeAccountMembers){
|
||||
if(m.id === membership.id){
|
||||
const { membership } =
|
||||
await $client.account.changeUserAccessWithinAccount.mutate({
|
||||
user_id,
|
||||
access
|
||||
});
|
||||
if (membership) {
|
||||
for (const m of this.activeAccountMembers) {
|
||||
if (m.id === membership.id) {
|
||||
m.access = membership.access;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async claimOwnershipOfAccount(){
|
||||
async claimOwnershipOfAccount() {
|
||||
const { $client } = useNuxtApp();
|
||||
const { memberships } = await $client.account.claimOwnershipOfAccount.mutate();
|
||||
if(memberships){
|
||||
const { memberships } =
|
||||
await $client.account.claimOwnershipOfAccount.mutate();
|
||||
if (memberships) {
|
||||
this.activeAccountMembers = memberships;
|
||||
this.activeMembership!.access = ACCOUNT_ACCESS.OWNER
|
||||
this.activeMembership!.access = ACCOUNT_ACCESS.OWNER;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Note } from ".prisma/client"
|
||||
import { defineStore, storeToRefs } from "pinia"
|
||||
import { Ref } from "vue";
|
||||
import { Note } from '.prisma/client';
|
||||
import { defineStore, storeToRefs } from 'pinia';
|
||||
import { Ref } from 'vue';
|
||||
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
export const useNotesStore = defineStore('notes', () => {
|
||||
const accountStore = useAccountStore()
|
||||
const accountStore = useAccountStore();
|
||||
const { activeAccountId } = storeToRefs(accountStore);
|
||||
|
||||
let _notes: Ref<Note[]> = ref([]);
|
||||
@@ -17,37 +17,45 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
async function fetchNotesForCurrentUser() {
|
||||
const { $client } = useNuxtApp();
|
||||
const { notes } = await $client.notes.getForActiveAccount.query();
|
||||
if(notes){
|
||||
if (notes) {
|
||||
_notes.value = notes;
|
||||
}
|
||||
}
|
||||
|
||||
async function createNote(note_text: string) {
|
||||
const { $client } = useNuxtApp();
|
||||
const { note } = await $client.notes.createNote.mutate({note_text});
|
||||
if(note){
|
||||
const { note } = await $client.notes.createNote.mutate({ note_text });
|
||||
if (note) {
|
||||
_notes.value.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNote(note_id: number) {
|
||||
const { $client } = useNuxtApp();
|
||||
const { note } = await $client.notes.deleteNote.mutate({note_id});
|
||||
if(note){
|
||||
const { note } = await $client.notes.deleteNote.mutate({ note_id });
|
||||
if (note) {
|
||||
_notes.value = _notes.value.filter(n => n.id !== note.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateAINoteFromPrompt(user_prompt: string) {
|
||||
const { $client } = useNuxtApp();
|
||||
const { noteText } = await $client.notes.generateAINoteFromPrompt.query({user_prompt});
|
||||
return noteText?noteText:'';
|
||||
const { noteText } = await $client.notes.generateAINoteFromPrompt.query({
|
||||
user_prompt
|
||||
});
|
||||
return noteText ? noteText : '';
|
||||
}
|
||||
|
||||
// if the active account changes, fetch notes again (i.e dynamic.. probabl overkill)
|
||||
watch(activeAccountId, async (val, oldVal)=> {
|
||||
await fetchNotesForCurrentUser()
|
||||
watch(activeAccountId, async (val, oldVal) => {
|
||||
await fetchNotesForCurrentUser();
|
||||
});
|
||||
|
||||
return { notes: _notes, fetchNotesForCurrentUser, createNote, deleteNote, generateAINoteFromPrompt}
|
||||
return {
|
||||
notes: _notes,
|
||||
fetchNotesForCurrentUser,
|
||||
createNote,
|
||||
deleteNote,
|
||||
generateAINoteFromPrompt
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,45 +1,51 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
export interface Notification{
|
||||
export interface Notification {
|
||||
message: string;
|
||||
type: NotificationType;
|
||||
notifyTime: number;
|
||||
}
|
||||
|
||||
export enum NotificationType{
|
||||
export enum NotificationType {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
Error
|
||||
}
|
||||
|
||||
interface State {
|
||||
notifications: Notification[],
|
||||
notificationsArchive: Notification[],
|
||||
notifications: Notification[];
|
||||
notificationsArchive: Notification[];
|
||||
}
|
||||
|
||||
export const useNotifyStore = defineStore('notify', {
|
||||
state: (): State => {
|
||||
return {
|
||||
notifications: [],
|
||||
notificationsArchive: [],
|
||||
}
|
||||
notificationsArchive: []
|
||||
};
|
||||
},
|
||||
actions: {
|
||||
notify(messageOrError: unknown, type:NotificationType){
|
||||
let message: string = "";
|
||||
notify(messageOrError: unknown, type: NotificationType) {
|
||||
let message: string = '';
|
||||
if (messageOrError instanceof Error) message = messageOrError.message;
|
||||
if (typeof messageOrError === "string") message = messageOrError;
|
||||
const notification: Notification = {message, type, notifyTime: Date.now()};
|
||||
if (typeof messageOrError === 'string') message = messageOrError;
|
||||
const notification: Notification = {
|
||||
message,
|
||||
type,
|
||||
notifyTime: Date.now()
|
||||
};
|
||||
this.notifications.push(notification);
|
||||
setTimeout(this.removeNotification.bind(this), 5000, notification);
|
||||
},
|
||||
removeNotification(notification: Notification){
|
||||
this.notifications = this.notifications.filter(n => n.notifyTime != notification.notifyTime);
|
||||
},
|
||||
removeNotification(notification: Notification) {
|
||||
this.notifications = this.notifications.filter(
|
||||
n => n.notifyTime != notification.notifyTime
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default {
|
||||
plugins:[require("@tailwindcss/typography"), require("daisyui")],
|
||||
plugins: [require("@tailwindcss/typography"), require("daisyui")],
|
||||
daisyui: {
|
||||
styled: true,
|
||||
themes: ["acid", "night"],
|
||||
|
||||
Reference in New Issue
Block a user