Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions app/components/Header/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup>
import { dark, experimental__simple } from '@clerk/themes';

const route = useRoute();
const colorMode = useColorMode();
</script>

Expand Down Expand Up @@ -36,23 +37,24 @@ const colorMode = useColorMode();
<UButton loading variant="ghost" color="neutral" />
</template>
</UColorModeButton>
<SignedOut>
<SignInButton>
<template v-if="route.fullPath !== '/login' && route.fullPath !== '/signup'">
<SignedOut>
<UButton
class="cursor-pointer"
variant="ghost"
size="md"
icon="lucide:log-in"
:square="true"
color="neutral"
to="/login"
/>
</SignInButton>
</SignedOut>
<SignedIn>
<UserButton
:appearance="{ baseTheme: colorMode.value === 'dark' ? dark : experimental__simple }"
/>
</SignedIn>
</SignedOut>
<SignedIn>
<UserButton
:appearance="{ baseTheme: colorMode.value === 'dark' ? dark : experimental__simple }"
/>
</SignedIn>
</template>
</div>
</header>
</template>
143 changes: 143 additions & 0 deletions app/pages/login.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<script setup lang="ts">
import * as z from 'zod';
import type { FormSubmitEvent, AuthFormField } from '@nuxt/ui';

const toast = useToast();
const { signIn, isLoaded } = useSignIn();

const fields: AuthFormField[] = [
{
name: 'email',
type: 'email',
label: 'Email',
placeholder: 'Enter your email',
required: true,
},
{
name: 'password',
label: 'Password',
type: 'password',
placeholder: 'Enter your password',
required: true,
},
{
name: 'remember',
label: 'Remember me',
type: 'checkbox',
},
];

const isLoading = ref(false);

async function signInWithOAuth(provider: 'oauth_google' | 'oauth_github') {
if (!isLoaded.value || !signIn.value) return;

try {
await signIn.value.authenticateWithRedirect({
strategy: provider,
redirectUrl: '/sso-callback',
redirectUrlComplete: '/',
});
} catch (error: unknown) {
const providerName = provider.replace('oauth_', '');
const errorMessage =
error &&
typeof error === 'object' &&
'errors' in error &&
Array.isArray(error.errors) &&
error.errors[0] &&
typeof error.errors[0] === 'object' &&
'message' in error.errors[0]
? error.errors[0].message
: `Failed to sign in with ${providerName}`;

toast.add({
title: 'Error',
description: String(errorMessage),
color: 'error',
});
}
}

const providers = [
{
label: 'Google',
icon: 'i-simple-icons-google',
onClick: () => signInWithOAuth('oauth_google'),
},
{
label: 'GitHub',
icon: 'i-simple-icons-github',
onClick: () => signInWithOAuth('oauth_github'),
},
];

const schema = z.object({
email: z.email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters long'),
});

type Schema = z.output<typeof schema>;

async function onSubmit(event: FormSubmitEvent<Schema>) {
if (!isLoaded.value || !signIn.value) return;

isLoading.value = true;

try {
const result = await signIn.value.create({
identifier: event.data.email,
password: event.data.password,
});

if (result.status === 'complete') {
await navigateTo('/');
toast.add({
title: 'Success',
description: 'You have been signed in successfully.',
color: 'success',
});
}
} catch (error: unknown) {
const errorMessage =
error &&
typeof error === 'object' &&
'errors' in error &&
Array.isArray(error.errors) &&
error.errors[0] &&
typeof error.errors[0] === 'object' &&
'message' in error.errors[0]
? error.errors[0].message
: 'Failed to sign in';

toast.add({
title: 'Error',
description: String(errorMessage),
color: 'error',
});
} finally {
isLoading.value = false;
}
}
</script>

<template>
<section class="flex flex-col items-center justify-center gap-11 m-12">
<UPageCard class="w-full max-w-md">
<UAuthForm
:schema="schema"
title="Login"
description="Enter your credentials to access your account."
icon="i-lucide-user"
:fields="fields"
:providers="providers"
:loading="isLoading"
@submit="onSubmit"
/>
</UPageCard>
<div class="text-sm font-medium text-gray-600 dark:text-gray-400">
Don't have an account?
<NuxtLink to="/signup" class="font-bold hover:underline">Sign up here</NuxtLink>
</div>
</section>
</template>
148 changes: 148 additions & 0 deletions app/pages/signup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<script setup lang="ts">
import * as z from 'zod';
import type { FormSubmitEvent, AuthFormField } from '@nuxt/ui';

const toast = useToast();
const { signUp, isLoaded } = useSignUp();

const fields: AuthFormField[] = [
{
name: 'email',
type: 'email',
label: 'Email',
placeholder: 'Enter your email',
required: true,
},
{
name: 'password',
label: 'Password',
type: 'password',
placeholder: 'Enter your password',
required: true,
},
{
name: 'first-name',
label: 'First Name',
type: 'text',
placeholder: 'Enter your first name',
},
];

const isLoading = ref(false);

async function signUpWithOAuth(provider: 'oauth_google' | 'oauth_github') {
if (!isLoaded.value || !signUp.value) return;

try {
await signUp.value.authenticateWithRedirect({
strategy: provider,
redirectUrl: '/sso-callback',
redirectUrlComplete: '/',
});
} catch (error: unknown) {
const providerName = provider.replace('oauth_', '');
const errorMessage =
error &&
typeof error === 'object' &&
'errors' in error &&
Array.isArray(error.errors) &&
error.errors[0] &&
typeof error.errors[0] === 'object' &&
'message' in error.errors[0]
? error.errors[0].message
: `Failed to sign in with ${providerName}`;

toast.add({
title: 'Error',
description: String(errorMessage),
color: 'error',
});
}
}

const providers = [
{
label: 'Google',
icon: 'i-simple-icons-google',
onClick: () => signUpWithOAuth('oauth_google'),
},
{
label: 'GitHub',
icon: 'i-simple-icons-github',
onClick: () => signUpWithOAuth('oauth_github'),
},
];

const schema = z.object({
email: z.email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters long'),
'first-name': z.string().min(1, 'First name is required'),
});

type Schema = z.output<typeof schema>;

async function onSubmit(event: FormSubmitEvent<Schema>) {
// TODO: Email verification functionality
if (!isLoaded.value || !signUp.value) return;

isLoading.value = true;

try {
const result = await signUp.value.create({
emailAddress: event.data.email,
password: event.data.password,
firstName: event.data['first-name'],
});

if (result.status === 'complete') {
await navigateTo('/');
toast.add({
title: 'Success',
description: 'Account created successfully!',
color: 'success',
});
}
} catch (error: unknown) {
const errorMessage =
error &&
typeof error === 'object' &&
'errors' in error &&
Array.isArray(error.errors) &&
error.errors[0] &&
typeof error.errors[0] === 'object' &&
'message' in error.errors[0]
? error.errors[0].message
: 'Failed to create account';

toast.add({
title: 'Error',
description: String(errorMessage),
color: 'error',
});
} finally {
isLoading.value = false;
}
}
</script>

<template>
<section class="flex flex-col items-center justify-center gap-11 m-12">
<UPageCard class="w-full max-w-md">
<UAuthForm
:schema="schema"
title="Create Account"
description="Enter your details to create your account."
icon="i-lucide-user-plus"
:fields="fields"
:providers="providers"
:loading="isLoading"
@submit="onSubmit"
/>
</UPageCard>

<div class="text-sm font-medium text-gray-600 dark:text-gray-400">
Already have an account?
<NuxtLink to="/login" class="font-bold hover:underline">Login here</NuxtLink>
</div>
</section>
</template>
21 changes: 21 additions & 0 deletions app/pages/sso-callback.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script setup lang="ts">
// This page handles OAuth redirects from social providers
// Clerk will automatically process the callback and redirect appropriately

onMounted(() => {
// If for some reason we end up here without being redirected,
// redirect back to home after a short delay
setTimeout(() => {
navigateTo('/');
}, 3000);
});
</script>

<template>
<div class="flex flex-col items-center justify-center min-h-screen">
<div class="flex flex-col items-center gap-4">
<UIcon name="i-lucide-loader-2" class="w-8 h-8 animate-spin text-primary-500" />
<p class="text-gray-600 dark:text-gray-400">Completing sign in...</p>
</div>
</div>
</template>
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,15 @@
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3",
"vue": "^3.5.22",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@iconify-json/lucide": "^1.2.69",
"@iconify-json/pepicons-pop": "^1.2.3",
"@iconify-json/simple-icons": "^1.2.54",
"@nuxt/test-utils": "3.19.2",
"@vue/test-utils": "^2.4.6",
"happy-dom": "^19.0.2",
Expand Down
Loading