refactor: ♻️ Rewrite password reset page, polish auth

This commit is contained in:
Jesse Wierzbinski 2024-12-03 12:30:10 +01:00
parent 7cd71f252e
commit f672ce5a69
No known key found for this signature in database
12 changed files with 205 additions and 105 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -43,10 +43,6 @@ const form = useForm({
validationSchema: formSchema, validationSchema: formSchema,
}); });
const onSubmit = form.handleSubmit((values) => {
console.info("Form submitted!", values);
});
const redirectUrl = new URL("/api/auth/login", `https://${instance.domain}`); const redirectUrl = new URL("/api/auth/login", `https://${instance.domain}`);
const params = useUrlSearchParams(); const params = useUrlSearchParams();
@ -85,7 +81,7 @@ const issuerRedirectUrl = (issuerId: string) => {
<template> <template>
<div class="grid gap-6"> <div class="grid gap-6">
<form @submit="onSubmit" method="post" :action="redirectUrl.toString()"> <form @submit="form.submitForm" method="post" :action="redirectUrl.toString()">
<div class="grid gap-6"> <div class="grid gap-6">
<FormField v-slot="{ componentField }" name="identifier"> <FormField v-slot="{ componentField }" name="identifier">
<FormItem> <FormItem>
@ -93,7 +89,7 @@ const issuerRedirectUrl = (issuerId: string) => {
Email (or username) Email (or username)
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input placeholder="petergriffin" type="email" auto-capitalize="none" <Input placeholder="petergriffin" type="text" auto-capitalize="none"
auto-complete="idenfifier" auto-correct="off" :disabled="isLoading" auto-complete="idenfifier" auto-correct="off" :disabled="isLoading"
v-bind="componentField" /> v-bind="componentField" />
</FormControl> </FormControl>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
import { type AlertVariants, alertVariants } from ".";
const props = defineProps<{
class?: HTMLAttributes["class"];
variant?: AlertVariants["variant"];
}>();
</script>
<template>
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
<slot />
</h5>
</template>

View file

@ -0,0 +1,23 @@
import { type VariantProps, cva } from "class-variance-authority";
export { default as Alert } from "./Alert.vue";
export { default as AlertDescription } from "./AlertDescription.vue";
export { default as AlertTitle } from "./AlertTitle.vue";
export const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
export type AlertVariants = VariantProps<typeof alertVariants>;

View file

@ -4,7 +4,6 @@ export default defineNuxtConfig({
"@nuxtjs/tailwindcss", "@nuxtjs/tailwindcss",
"@vueuse/nuxt", "@vueuse/nuxt",
"@nuxt/fonts", "@nuxt/fonts",
"@vee-validate/nuxt",
"nuxt-security", "nuxt-security",
"@vite-pwa/nuxt", "@vite-pwa/nuxt",
"shadcn-nuxt", "shadcn-nuxt",
@ -258,15 +257,6 @@ export default defineNuxtConfig({
}, },
], ],
}, },
veeValidate: {
autoImports: true,
componentNames: {
Form: "VeeForm",
Field: "VeeField",
FieldArray: "VeeFieldArray",
ErrorMessage: "VeeErrorMessage",
},
},
runtimeConfig: { runtimeConfig: {
public: { public: {
apiHost: "https://social.lysand.org", apiHost: "https://social.lysand.org",

View file

@ -33,7 +33,6 @@
"@nuxt/fonts": "^0.10.2", "@nuxt/fonts": "^0.10.2",
"@nuxtjs/color-mode": "3.5.2", "@nuxtjs/color-mode": "3.5.2",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@vee-validate/nuxt": "^4.14.7",
"@vee-validate/zod": "^4.14.7", "@vee-validate/zod": "^4.14.7",
"@versia/client": "0.1.3", "@versia/client": "0.1.3",
"@vite-pwa/nuxt": "^0.10.6", "@vite-pwa/nuxt": "^0.10.6",

View file

@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Client } from "@versia/client"; import { Client } from "@versia/client";
import { Loader } from "lucide-vue-next"; import { AlertCircle, Loader } from "lucide-vue-next";
import UserAuthForm from "~/components/oauth/login.vue"; import UserAuthForm from "~/components/oauth/login.vue";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { NuxtLink } from "#components"; import { NuxtLink } from "#components";
@ -11,6 +12,7 @@ const {
const host = new URL(apiHost).host; const host = new URL(apiHost).host;
const instance = useInstanceFromClient(new Client(new URL(useBaseUrl().value))); const instance = useInstanceFromClient(new Client(new URL(useBaseUrl().value)));
const { error, error_description } = useUrlSearchParams();
</script> </script>
<template> <template>
@ -44,6 +46,13 @@ const instance = useInstanceFromClient(new Client(new URL(useBaseUrl().value)));
</div> </div>
<div class="lg:p-8 w-full max-w-xl"> <div class="lg:p-8 w-full max-w-xl">
<div class="mx-auto flex w-full flex-col justify-center space-y-10 sm:w-[350px]"> <div class="mx-auto flex w-full flex-col justify-center space-y-10 sm:w-[350px]">
<Alert v-if="error" variant="destructive" class="mb-4">
<AlertCircle class="w-4 h-4" />
<AlertTitle>{{ error }}</AlertTitle>
<AlertDescription>
{{ error_description }}
</AlertDescription>
</Alert>
<div class="flex flex-col space-y-2 text-center"> <div class="flex flex-col space-y-2 text-center">
<h1 class="text-2xl font-semibold tracking-tight"> <h1 class="text-2xl font-semibold tracking-tight">
Log in to your account. Log in to your account.

View file

@ -2,7 +2,8 @@
<div class="flex h-svh items-center justify-center px-6 py-12 lg:px-8 bg-center bg-no-repeat bg-cover" :style="{ <div class="flex h-svh items-center justify-center px-6 py-12 lg:px-8 bg-center bg-no-repeat bg-cover" :style="{
backgroundImage: 'url(/images/banner.webp)' backgroundImage: 'url(/images/banner.webp)'
}"> }">
<Card class="w-full max-w-md"> <Card class="w-full max-w-md" as="form" method="POST" :action="url.pathname.replace('/oauth/consent', '/oauth/authorize')">
<input type="hidden" v-for="([key, value]) in url.searchParams" :key="key" :name="key" :value="value" />
<CardHeader> <CardHeader>
<CardTitle as="h1" class="text-2xl break-words">Authorize &ldquo;{{ application }}&rdquo;?</CardTitle> <CardTitle as="h1" class="text-2xl break-words">Authorize &ldquo;{{ application }}&rdquo;?</CardTitle>
</CardHeader> </CardHeader>

View file

@ -1,88 +1,129 @@
<template> <template>
<div class="flex min-h-screen relative flex-col gap-10 justify-center py-12 px-8"> <div class="flex h-svh items-center justify-center px-6 py-12 lg:px-8 bg-center bg-no-repeat bg-cover" :style="{
<img crossorigin="anonymous" src="https://cdn.versia.pub/branding/icon.svg" alt="Versia logo" backgroundImage: 'url(/images/banner.webp)'
class="mx-auto hidden md:inline-block h-20 ring-1 ring-white/20 rounded" /> }">
<div v-if="validUrlParameters" class="mx-auto w-full max-w-md"> <Card v-if="params.success" class="w-full max-w-md">
<VeeForm class="flex flex-col gap-y-6" method="POST" :validation-schema="schema" action="/api/auth/reset"> <CardHeader>
<input type="hidden" name="token" :value="params.token" /> <CardTitle>Success</CardTitle>
<CardDescription>
<h1 class="font-bold text-2xl text-gray-50 text-center tracking-tight">Reset your password</h1> Your password has been reset. You can now log in with your new password.
</CardDescription>
<div v-if="error" class="ring-1 ring-white/10 rounded p-4 bg-red-500 text-white"> </CardHeader>
<h2 class="font-bold text-lg">An error occured</h2> <CardFooter class="grid">
<span class="text-sm">{{ params.error_description }}</span> <Button :as="NuxtLink" href="/" variant="default">
</div> Back to front page
</Button>
<VeeField name="password" v-slot="{ errorMessage, field }" validate-on-change> </CardFooter>
<Field> </Card>
<LabelAndError> <Card v-else class="w-full max-w-md">
<Label for="password">New password</Label> <form method="POST" action="/api/auth/reset" @submit="form.submitForm">
<FieldError v-if="errorMessage">{{ errorMessage }}</FieldError> <CardHeader>
</LabelAndError> <Alert v-if="params.login_reset" variant="default" class="mb-4">
<PasswordInput id="password" placeholder="hunter2" autocomplete="new-password" required <AlertCircle class="w-4 h-4" />
v-bind="field" :is-invalid="!!errorMessage" :show-strength="true" /> <AlertTitle>Info</AlertTitle>
</Field> <AlertDescription>
</VeeField> Your password has been reset by an administrator. Please change it here.
</AlertDescription>
<VeeField name="password-confirm" as="div" v-slot="{ errors, field }" validate-on-change> </Alert>
<Field> <Alert v-if="params.error" variant="destructive" class="mb-4">
<LabelAndError> <AlertCircle class="w-4 h-4" />
<Label for="password-confirm">Confirm password</Label> <AlertTitle>{{ params.error }}</AlertTitle>
<FieldError v-if="errors.length > 0">{{ errors[0] }}</FieldError> <AlertDescription>
</LabelAndError> {{ params.error_description }}
<PasswordInput id="password-confirm" placeholder="hunter2" autocomplete="new-password" required </AlertDescription>
v-bind="field" :is-invalid="errors.length > 0" /> </Alert>
</Field> <CardTitle as="h1">Reset your password</CardTitle>
</VeeField> <CardDescription>
Enter your new password below. Make sure to put it in a password manager.
<p class="text-xs font-semibold text-red-300">This will reset your </CardDescription>
password. Make sure to put it in a password manager. </CardHeader>
</p> <CardContent class="grid gap-6">
<FormField v-slot="{ componentField }" name="token">
<Button theme="primary" type="submit" class="w-full">Reset</Button> <FormItem>
</VeeForm> <FormControl>
</div> <input type="hidden" v-bind="componentField" />
<div v-else-if="params.success"> </FormControl>
<h1 class="text-2xl font-bold tracking-tight text-gray-50 sm:text-4xl text-center">Password reset </FormItem>
successful! </FormField>
</h1> <FormField v-slot="{ componentField }" name="password">
<p class="mt-6 text-lg leading-8 text-gray-300 text-center"> <FormItem>
You can now login to your account with your new password. <FormLabel>
</p> New password
</div> </FormLabel>
<div v-else class="mx-auto max-w-md"> <FormControl>
<h1 class="text-2xl font-bold tracking-tight text-gray-50 sm:text-4xl">Invalid access <Input placeholder="hunter2" type="password" auto-capitalize="none" auto-correct="off"
parameters v-bind="componentField" />
</h1> </FormControl>
<p class="mt-6 text-lg leading-8 text-gray-300">This page should be accessed <FormMessage />
through a valid password reset request. Please ask your admin to reset your password. </FormItem>
</p> </FormField>
<FormField v-slot="{ componentField }" name="password-confirm">
<p class="mt-6 text-lg leading-8 text-gray-300"> <FormItem>
Found a problem? Report it on <a href="https://github.com/versia-pub/server/issues/new/choose" <FormLabel>
target="_blank" class="underline text-primary2-700">the issue tracker</a>. Confirm password
</p> </FormLabel>
</div> <FormControl>
<Input placeholder="hunter2" type="password" auto-capitalize="none" auto-correct="off"
v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</CardContent>
<CardFooter class="grid gap-2">
<Button variant="default" type="submit">Reset</Button>
</CardFooter>
</form>
</Card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { toTypedSchema } from "@vee-validate/zod"; import { toTypedSchema } from "@vee-validate/zod";
import { AlertCircle } from "lucide-vue-next";
import { useForm } from "vee-validate";
import { z } from "zod"; import { z } from "zod";
import FieldError from "~/components/inputs/field-error.vue"; import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import Field from "~/components/inputs/field.vue"; import { Button } from "~/components/ui/button";
import LabelAndError from "~/components/inputs/label-and-error.vue"; import {
import Label from "~/components/inputs/label.vue"; Card,
import PasswordInput from "~/components/inputs/password-input.vue"; CardContent,
import Button from "~/packages/ui/components/buttons/button.vue"; CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { NuxtLink } from "#components";
identity.value = null; identity.value = null;
const schema = toTypedSchema( const formSchema = toTypedSchema(
z z
.object({ .object({
password: z.string().min(3).max(100), token: z.string(),
"password-confirm": z.string().min(3).max(100), password: z
.string()
.min(3, {
message: "Must be at least 3 characters long",
})
.max(100, {
message: "Must be at most 100 characters long",
}),
"password-confirm": z
.string()
.min(3, {
message: "Must be at least 3 characters long",
})
.max(100, {
message: "Must be at most 100 characters long",
}),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.password !== data["password-confirm"]) { if (data.password !== data["password-confirm"]) {
@ -97,14 +138,11 @@ const schema = toTypedSchema(
); );
const params = useUrlSearchParams(); const params = useUrlSearchParams();
let error = params.error;
let errorDescription = params.error_description;
if (params.login_reset) { const form = useForm({
error = "Login reset"; validationSchema: formSchema,
errorDescription = initialValues: {
"Your password has been reset by an administrator. Please change it here."; token: (params.token as string) ?? undefined,
} },
});
const validUrlParameters = !!params.token;
</script> </script>

View file

@ -1,5 +1,5 @@
<template> <template>
<SettingsSidebar> <!-- <SettingsSidebar>
<template #behaviour> <template #behaviour>
<SettingPage :page="SettingPages.Behaviour" /> <SettingPage :page="SettingPages.Behaviour" />
</template> </template>
@ -12,11 +12,11 @@
<template #emojis> <template #emojis>
<EmojiEditor /> <EmojiEditor />
</template> </template>
</SettingsSidebar> </SettingsSidebar> -->
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import EmojiEditor from "~/components/settings/emojis/emojis.vue"; /* import EmojiEditor from "~/components/settings/emojis/emojis.vue";
import SettingPage from "~/components/settings/page.vue"; import SettingPage from "~/components/settings/page.vue";
import ProfileEditor from "~/components/settings/profile-editor.vue"; import ProfileEditor from "~/components/settings/profile-editor.vue";
import SettingsSidebar from "~/components/sidebars/settings-sidebar.vue"; import SettingsSidebar from "~/components/sidebars/settings-sidebar.vue";
@ -24,5 +24,5 @@ import { SettingPages } from "~/settings";
definePageMeta({ definePageMeta({
layout: "app", layout: "app",
}); }); */
</script> </script>