diff --git a/.gitignore b/.gitignore index a6838706e..fdc1fa958 100644 --- a/.gitignore +++ b/.gitignore @@ -144,4 +144,9 @@ dist .data apps/vault/.data -apps/shelve/.data \ No newline at end of file +apps/shelve/.data +.vercel +.env*.local +.tmp/ +.worktrees/ +.nuxtrc diff --git a/apps/shelve/.env.e2e.example b/apps/shelve/.env.e2e.example new file mode 100644 index 000000000..be48d1d81 --- /dev/null +++ b/apps/shelve/.env.e2e.example @@ -0,0 +1,6 @@ +E2E_BASE_URL="" +E2E_DATABASE_URL="" +E2E_TEST_EMAIL="" +E2E_ADMIN_EMAIL="" +E2E_TEAM_SLUG="" +E2E_VERCEL_BYPASS="" diff --git a/apps/shelve/.env.example b/apps/shelve/.env.example index c8d2e0739..4c42b166f 100644 --- a/apps/shelve/.env.example +++ b/apps/shelve/.env.example @@ -6,8 +6,9 @@ NUXT_OAUTH_GOOGLE_CLIENT_ID=your_value TURBO_TEAM=your_value NUXT_OAUTH_GITHUB_CLIENT_ID=your_value NUXT_PRIVATE_ENCRYPTION_KEY=your_value +NUXT_AUTH_ALLOW_LEGACY_CLI=true NUXT_OAUTH_GITHUB_CLIENT_SECRET=your_value -NUXT_SESSION_PASSWORD=your_value +BETTER_AUTH_SECRET=your_value TURBO_TOKEN=your_value NUXT_PRIVATE_RESEND_API_KEY=your_value NUXT_PRIVATE_RESEND_WEBHOOK_SECRET=your_value diff --git a/apps/shelve/.gitignore b/apps/shelve/.gitignore index 2f79449bd..235bffad5 100644 --- a/apps/shelve/.gitignore +++ b/apps/shelve/.gitignore @@ -148,4 +148,9 @@ dist .turbo .data -.wrangler \ No newline at end of file +.wrangler +.vercel +.env*.local +.nuxtrc +playwright-report/ +test-results/ diff --git a/apps/shelve/app/auth.config.ts b/apps/shelve/app/auth.config.ts new file mode 100644 index 000000000..76b4bc0f7 --- /dev/null +++ b/apps/shelve/app/auth.config.ts @@ -0,0 +1,6 @@ +import { defineClientAuth } from '@onmax/nuxt-better-auth/config' +import { adminClient, emailOTPClient } from 'better-auth/client/plugins' + +export default defineClientAuth({ + plugins: [emailOTPClient(), adminClient()], +}) diff --git a/apps/shelve/app/components/CliInstall.vue b/apps/shelve/app/components/CliInstall.vue index 4056777ce..36c2b6366 100644 --- a/apps/shelve/app/components/CliInstall.vue +++ b/apps/shelve/app/components/CliInstall.vue @@ -1,5 +1,5 @@ @@ -32,7 +38,6 @@ function open() { >({ email: undefined }) -const loading = ref(false) +const sendVerificationOtp = useAuthClientAction((authClient) => authClient.emailOtp.sendVerificationOtp) +const loading = computed(() => sendVerificationOtp.status.value === 'pending') async function onSubmit(event: FormSubmitEvent) { - loading.value = true - try { - await $fetch('/api/auth/otp/send', { - method: 'POST', - body: { email: event.data.email } - }) - + await sendVerificationOtp.execute({ + email: event.data.email, + type: 'sign-in' + }) + + if (sendVerificationOtp.status.value === 'error') { + toast.error(getAuthErrorMessage(sendVerificationOtp.error.value, 'Failed to send verification code')) + } else { emit('emailSubmitted', event.data.email) toast.success('Check your email for the verification code') - } catch (error: any) { - toast.error(error.data?.message || 'Failed to send verification code') - } finally { - loading.value = false } } diff --git a/apps/shelve/app/components/auth/OtpForm.vue b/apps/shelve/app/components/auth/OtpForm.vue index 23c6da67f..80ea609d3 100644 --- a/apps/shelve/app/components/auth/OtpForm.vue +++ b/apps/shelve/app/components/auth/OtpForm.vue @@ -12,7 +12,9 @@ const emit = defineEmits<{ const router = useRouter() const otp = ref(props.prefilledOtp ? props.prefilledOtp.split('') : []) -const loading = ref(false) +const signInEmailOtp = useSignIn('emailOtp') +const sendVerificationOtp = useAuthClientAction((authClient) => authClient.emailOtp.sendVerificationOtp) +const loading = computed(() => signInEmailOtp.status.value === 'pending' || sendVerificationOtp.status.value === 'pending') onMounted(async () => { if (props.prefilledOtp && props.prefilledOtp.length === 6) { @@ -23,41 +25,37 @@ onMounted(async () => { async function handleOtpComplete(value: string[]) { if (value.length !== 6) return - loading.value = true - try { - await $fetch('/api/auth/otp/verify', { - method: 'POST', - body: { - email: props.email, - code: value.join('') + await signInEmailOtp.execute({ + email: props.email, + otp: value.join('') + }, { + onSuccess: async () => { + emit('otpVerified') + toast.success('Login successful!') + if (props.redirectUrl) { + await router.push(props.redirectUrl) + } else { + await reloadNuxtApp() } - }) - - emit('otpVerified') - toast.success('Login successful!') - - if (props.redirectUrl) { - await router.push(props.redirectUrl) - } else { - reloadNuxtApp() } - } catch (error: any) { - toast.error(error.data?.message || 'Invalid verification code') + }) + + if (signInEmailOtp.status.value === 'error') { + toast.error(getAuthErrorMessage(signInEmailOtp.error.value, 'Invalid verification code')) otp.value = [] - } finally { - loading.value = false } } async function resendCode() { - try { - await $fetch('/api/auth/otp/send', { - method: 'POST', - body: { email: props.email } - }) + await sendVerificationOtp.execute({ + email: props.email, + type: 'sign-in' + }) + + if (sendVerificationOtp.status.value === 'error') { + toast.error(getAuthErrorMessage(sendVerificationOtp.error.value, 'Failed to resend code')) + } else { toast.success('New verification code sent') - } catch (error: any) { - toast.error('Failed to resend code') } } diff --git a/apps/shelve/app/composables/useLogout.ts b/apps/shelve/app/composables/useLogout.ts index 699291705..43a55010a 100644 --- a/apps/shelve/app/composables/useLogout.ts +++ b/apps/shelve/app/composables/useLogout.ts @@ -1,4 +1,7 @@ export async function useLogout() { - await useUserSession().clear() + const { signOut } = useUserSession() + const defaultTeamSlug = useCookie('defaultTeamSlug') + defaultTeamSlug.value = null + await signOut() navigateTo('/login') } diff --git a/apps/shelve/app/error.vue b/apps/shelve/app/error.vue index 7b5e1e7a3..da4c157ef 100644 --- a/apps/shelve/app/error.vue +++ b/apps/shelve/app/error.vue @@ -9,7 +9,7 @@ const { error } = defineProps() console.error(error) const router = useRouter() -const { clear } = useUserSession() +const { signOut } = useUserSession() const handleError = () => clearError({ redirect: '/' }) const goBack = () => { @@ -34,7 +34,7 @@ const clearCookies = async () => { const [name] = cookie.split('=') document.cookie = `${name?.trim()}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` }) - await clear() + await signOut() navigateTo('/login') } diff --git a/apps/shelve/app/layouts/app.vue b/apps/shelve/app/layouts/app.vue index 7eb1b05b3..2c8ae5803 100644 --- a/apps/shelve/app/layouts/app.vue +++ b/apps/shelve/app/layouts/app.vue @@ -21,7 +21,7 @@ const routeTitle = computed(() => { }) const title = computed(() => { - return route.path === `/${teamSlug.value}` ? `Welcome, ${user.value?.username}` : navigation.value?.title || routeTitle.value + return route.path === `/${teamSlug.value}` ? `Welcome, ${user.value?.name}` : navigation.value?.title || routeTitle.value }) diff --git a/apps/shelve/app/middleware/admin.ts b/apps/shelve/app/middleware/admin.ts index a8372f12e..acb1268e0 100644 --- a/apps/shelve/app/middleware/admin.ts +++ b/apps/shelve/app/middleware/admin.ts @@ -2,6 +2,7 @@ import { Role } from '@types' export default defineNuxtRouteMiddleware(() => { const { user } = useUserSession() + if (user.value && user.value.role !== Role.ADMIN) { toast.error('You are not authorized to access this page') return navigateTo('/') diff --git a/apps/shelve/app/middleware/auth.ts b/apps/shelve/app/middleware/auth.ts deleted file mode 100644 index c8c29e299..000000000 --- a/apps/shelve/app/middleware/auth.ts +++ /dev/null @@ -1,8 +0,0 @@ -export default defineNuxtRouteMiddleware(() => { - const { loggedIn } = useUserSession() - - if (!loggedIn.value) { - toast.error('You need to be logged in to access this page.') - return navigateTo('/login') - } -}) diff --git a/apps/shelve/app/middleware/guest.ts b/apps/shelve/app/middleware/guest.ts deleted file mode 100644 index ffef76ae0..000000000 --- a/apps/shelve/app/middleware/guest.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default defineNuxtRouteMiddleware(() => { - const { loggedIn } = useUserSession() - - if (loggedIn.value) { - return navigateTo('/') - } -}) diff --git a/apps/shelve/app/middleware/index-redirect.ts b/apps/shelve/app/middleware/index-redirect.ts index fc4941524..ec69a3a16 100644 --- a/apps/shelve/app/middleware/index-redirect.ts +++ b/apps/shelve/app/middleware/index-redirect.ts @@ -5,9 +5,9 @@ export default defineNuxtRouteMiddleware(async (to, from) => { if (from.path && from.path !== '/' && !from.path.startsWith('/auth')) return try { - const teams = await $fetch('/api/teams') - if (teams.length === 1) { - return navigateTo(`/${teams[0].slug}`) + const teams = await $fetch>('/api/teams') + if (teams?.length === 1) { + return navigateTo(`/${teams[0]?.slug}`) } } catch (error) { console.error('Failed to fetch teams in index-redirect middleware:', error) diff --git a/apps/shelve/app/pages/[teamSlug].vue b/apps/shelve/app/pages/[teamSlug].vue index 989b413f5..2994b4fc2 100644 --- a/apps/shelve/app/pages/[teamSlug].vue +++ b/apps/shelve/app/pages/[teamSlug].vue @@ -1,7 +1,8 @@ @@ -72,10 +73,10 @@ useSeoMeta({
- +

- Welcome back, {{ user?.username }} + Welcome back, {{ user?.name }}

Select a team to get started diff --git a/apps/shelve/app/pages/invite/[token].vue b/apps/shelve/app/pages/invite/[token].vue index 84f2a6b07..dfbcc33e6 100644 --- a/apps/shelve/app/pages/invite/[token].vue +++ b/apps/shelve/app/pages/invite/[token].vue @@ -10,7 +10,8 @@ const route = useRoute() const router = useRouter() const token = route.params.token as string -const { loggedIn, user } = useUserSession() +const { loggedIn, user, signOut, fetchSession } = useUserSession() +const signInSocial = useSignIn('social') const { title, auth: { isGithubEnabled, isGoogleEnabled, isEmailEnabled } } = useAppConfig() const { data: invitation, status, error } = await useFetch(`/api/invitations/${token}`, { @@ -57,7 +58,7 @@ async function acceptInvitation() { method: 'POST', }) - await useUserSession().fetch() + await fetchSession() toast.success('Invitation accepted! Welcome to the team.') if (result.teamSlug) { @@ -65,8 +66,8 @@ async function acceptInvitation() { } else { await router.push('/') } - } catch (err: any) { - toast.error(err.data?.message || 'Failed to accept invitation') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error, 'Failed to accept invitation')) } finally { accepting.value = false } @@ -80,22 +81,29 @@ async function declineInvitation() { }) toast.success('Invitation declined') await router.push('/login') - } catch (err: any) { - toast.error(err.data?.message || 'Failed to decline invitation') + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error, 'Failed to decline invitation')) } finally { declining.value = false } } -function loginWithRedirect(provider: string) { +async function loginWithRedirect(provider: string) { const redirect = `/invite/${token}` - window.location.href = `/auth/${provider}?redirect=${encodeURIComponent(redirect)}` + await signInSocial.execute({ + provider, + callbackURL: redirect, + }) + + if (signInSocial.status.value === 'error') { + toast.error(getAuthErrorMessage(signInSocial.error.value, `Failed to sign in with ${provider}`)) + } } async function logoutAndRedirect() { - await useUserSession().clear() + await signOut() const redirect = `/invite/${token}` - await navigateTo(`/login?redirect=${encodeURIComponent(redirect)}`) + await navigateTo(`/login?redirect=${encodeURIComponent(redirect)}&source=invite`) } useSeoMeta({ @@ -254,7 +262,7 @@ useSeoMeta({ variant="outline" icon="heroicons:envelope" label="Continue with Email" - :to="`/login?redirect=${encodeURIComponent(`/invite/${token}`)}`" + :to="`/login?redirect=${encodeURIComponent(`/invite/${token}`)}&source=invite`" />

diff --git a/apps/shelve/app/pages/login.vue b/apps/shelve/app/pages/login.vue index a01c3f07f..22c36f129 100644 --- a/apps/shelve/app/pages/login.vue +++ b/apps/shelve/app/pages/login.vue @@ -5,15 +5,17 @@ const { title, auth: { isGithubEnabled, isGoogleEnabled, isEmailEnabled } } = us definePageMeta({ layout: 'auth', - middleware: 'guest' + auth: 'guest', }) const route = useRoute() +const router = useRouter() const showOtp = ref(false) const focus = ref(false) const email = ref(route.query.email as string || '') const prefilledOtp = ref(route.query.otp as string || '') -const redirectUrl = computed(() => route.query.redirect as string || '') +const redirectUrl = computed(() => typeof route.query.redirect === 'string' ? route.query.redirect : '') +const redirectSource = computed(() => typeof route.query.source === 'string' ? route.query.source : '') const authMode = ref<'oauth' | 'email'>('oauth') if (email.value && prefilledOtp.value) { @@ -27,7 +29,7 @@ if (route.query.error === 'github' || route.query.error === 'google') { closeButton: false, action: { label: 'Dismiss', - onClick: () => useRouter().push('/login') + onClick: () => router.push('/login') } }) } @@ -38,7 +40,7 @@ if (route.query.error === 'invalid-otp') { closeButton: false, action: { label: 'Dismiss', - onClick: () => useRouter().push('/login') + onClick: () => router.push('/login') } }) authMode.value = 'email' @@ -51,12 +53,16 @@ if (route.query.error === 'otp-verification') { closeButton: false, action: { label: 'Dismiss', - onClick: () => useRouter().push('/login') + onClick: () => router.push('/login') } }) authMode.value = 'email' } +if (redirectUrl.value && !route.query.error && redirectSource.value !== 'invite') { + toast.error('You need to be logged in to access this page.') +} + function handleEmailSubmitted(submittedEmail: string) { email.value = submittedEmail showOtp.value = true @@ -66,12 +72,10 @@ function handleBackToEmail() { showOtp.value = false email.value = '' prefilledOtp.value = '' - const router = useRouter() router.replace({ query: {} }) } function handleOtpVerified() { - const router = useRouter() router.replace({ query: {} }) } @@ -101,9 +105,8 @@ useSeoMeta({ class="flex flex-col items-center justify-center gap-4 overflow-hidden p-6 sm:p-10 min-w-xs sm:min-w-md" style="will-change: transform" > - - - - or
- + - - -
- - - definePageMeta({ layout: 'app', - middleware: ['auth'] + auth: 'user', }) useSeoMeta({ @@ -12,4 +12,3 @@ useSeoMeta({ - diff --git a/apps/shelve/app/pages/user/profile.vue b/apps/shelve/app/pages/user/profile.vue index f94d2084a..b289a9695 100644 --- a/apps/shelve/app/pages/user/profile.vue +++ b/apps/shelve/app/pages/user/profile.vue @@ -1,7 +1,7 @@