feat: ♻️ Rewrite registration UI

This commit is contained in:
Jesse Wierzbinski 2024-06-15 20:34:35 -10:00
parent fc6b44d237
commit fef4fa1e30
No known key found for this signature in database
8 changed files with 221 additions and 25 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -0,0 +1,12 @@
<template>
<InputsText type="checkbox" v-bind="$attrs, $props"
class="rounded disabled:hover:cursor-wait text-primary-700 !size-5" />
</template>
<script lang="ts" setup>
import type { InputHTMLAttributes } from "vue";
interface Props extends /* @vue-ignore */ InputHTMLAttributes {}
defineProps<Props>();
</script>

View file

@ -1,6 +1,16 @@
<template> <template>
<InputsText v-bind="$attrs, $props" :type="showPassword ? 'text' : 'password'" :spellcheck="false" <InputsText @input="e => content = (e.target as HTMLInputElement).value" v-bind="$attrs, $props" v-model="content"
autocomplete="new-password" /> :type="showPassword ? 'text' : 'password'" :spellcheck="false" />
<Progress.Root class="flex flex-row items-center gap-x-2" v-if="showStrength">
<Progress.Label class="text-xs text-gray-300 font-semibold w-12">
{{ text }}
</Progress.Label>
<Progress.Track class="rounded-sm w-full h-2 duration-300" :style="{
backgroundColor: color,
}">
<Progress.Range />
</Progress.Track>
</Progress.Root>
<Teleport :to="`#${$attrs.id}-label-slot`" v-if="teleport"> <Teleport :to="`#${$attrs.id}-label-slot`" v-if="teleport">
<button type="button" @click="showPassword = !showPassword" <button type="button" @click="showPassword = !showPassword"
class="text-xs ml-auto block mt-2 font-semibold text-gray-400"> class="text-xs ml-auto block mt-2 font-semibold text-gray-400">
@ -11,12 +21,16 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Progress } from "@ark-ui/vue";
import { passwordStrength } from "~/utils/passwords";
const showPassword = ref(false); const showPassword = ref(false);
const content = ref("");
import type { InputHTMLAttributes } from "vue"; import type { InputHTMLAttributes } from "vue";
interface Props extends /* @vue-ignore */ InputHTMLAttributes { interface Props extends /* @vue-ignore */ InputHTMLAttributes {
isInvalid?: boolean; isInvalid?: boolean;
showStrength?: boolean;
} }
defineOptions({ defineOptions({
@ -25,6 +39,31 @@ defineOptions({
defineProps<Props>(); defineProps<Props>();
const teleport = ref(false); const teleport = ref(false);
const strength = computed(() => passwordStrength(content.value ?? ""));
const text = computed(() => {
if (strength.value < 6) {
return "Weak";
}
if (strength.value < 7) {
return "Fair";
}
if (strength.value < 11) {
return "Good";
}
return "Strong";
});
const color = computed(() => {
if (strength.value < 6) {
return "red";
}
if (strength.value < 7) {
return "pink";
}
if (strength.value < 11) {
return "yellow";
}
return "green";
});
onMounted(() => { onMounted(() => {
teleport.value = true; teleport.value = true;

View file

@ -2,9 +2,46 @@ import type { LysandClient } from "@lysand-org/client";
// Return type of LysandClient.getInstance // Return type of LysandClient.getInstance
export type Instance = Awaited<ReturnType<LysandClient["getInstance"]>>["data"]; export type Instance = Awaited<ReturnType<LysandClient["getInstance"]>>["data"];
export type ExtendedDescription = Awaited<
ReturnType<LysandClient["getInstanceExtendedDescription"]>
>["data"];
export const useInstance = () => { export const useInstance = () => {
const identity = useCurrentIdentity(); const identity = useCurrentIdentity();
return computed(() => identity.value?.instance); return computed(() => identity.value?.instance);
}; };
export const useInstanceFromClient = (client: MaybeRef<LysandClient>) => {
if (!client) {
return ref(null as Instance | null);
}
const output = ref(null as Instance | null);
watchEffect(() => {
toValue(client)
?.getInstance()
.then((res) => {
output.value = res.data;
});
});
};
export const useTos = (client: MaybeRef<LysandClient>) => {
if (!client) {
return ref(null as ExtendedDescription | null);
}
const output = ref(null as ExtendedDescription | null);
watchEffect(() => {
toValue(client)
?.getInstanceTermsOfService()
.then((res) => {
output.value = res.data;
});
});
return output;
};

View file

@ -30,7 +30,7 @@
}, },
"dependencies": { "dependencies": {
"@ark-ui/vue": "^3.3.1", "@ark-ui/vue": "^3.3.1",
"@lysand-org/client": "^0.1.7", "@lysand-org/client": "^0.1.8",
"@nuxt/fonts": "^0.7.0", "@nuxt/fonts": "^0.7.0",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"@vee-validate/nuxt": "^4.13.1", "@vee-validate/nuxt": "^4.13.1",

View file

@ -54,8 +54,8 @@
<InputsLabel for="identifier">Username or Email</InputsLabel> <InputsLabel for="identifier">Username or Email</InputsLabel>
<InputsError v-if="errorMessage">{{ errorMessage }}</InputsError> <InputsError v-if="errorMessage">{{ errorMessage }}</InputsError>
</InputsLabelAndError> </InputsLabelAndError>
<InputsText id="identifier" placeholder="joemama" autocomplete="email" required v-bind="field" <InputsText id="identifier" placeholder="joemama" autocomplete="email username" required
:is-invalid="!!errorMessage" /> v-bind="field" :is-invalid="!!errorMessage" />
</InputsField> </InputsField>
</VeeField> </VeeField>

View file

@ -9,30 +9,76 @@
<span class="text-sm">{{ errors.error }}</span> <span class="text-sm">{{ errors.error }}</span>
</div> </div>
<VeeForm class="flex flex-col gap-y-6" @submit="s => register((s as any))" :validation-schema="schema"> <VeeForm class="flex flex-col gap-y-6" @submit="s => register((s as any))" :validation-schema="schema">
<h1 class="font-bold text-2xl text-gray-50 text-center tracking-tight">Passwords</h1> <h1 class="font-bold text-2xl text-gray-50 text-center tracking-tight">Account details</h1>
<VeeField name="password" as="div" v-slot="{ errorMessage, field }" validate-on-change> <VeeField name="username" v-slot="{ errorMessage, field }" validate-on-change>
<InputsField>
<InputsLabelAndError>
<InputsLabel for="username">Username</InputsLabel>
<InputsError v-if="errorMessage">{{ errorMessage }}</InputsError>
</InputsLabelAndError>
<InputsText id="username" type="text" placeholder="thespeedy" required v-bind="field"
:disabled="isLoading" :is-invalid="!!errorMessage" autocomplete="username"
:spellcheck="false" />
</InputsField>
</VeeField>
<VeeField name="email" v-slot="{ errorMessage, field }" validate-on-change>
<InputsField>
<InputsLabelAndError>
<InputsLabel for="email">Email address</InputsLabel>
<InputsError v-if="errorMessage">{{ errorMessage }}</InputsError>
</InputsLabelAndError>
<InputsText id="email" type="email" placeholder="joseph.jones@gmail.com" required v-bind="field"
:disabled="isLoading" :is-invalid="!!errorMessage" autocomplete="email" />
</InputsField>
</VeeField>
<VeeField name="password" v-slot="{ errorMessage, field }" validate-on-change>
<InputsField> <InputsField>
<InputsLabelAndError> <InputsLabelAndError>
<InputsLabel for="password">Password</InputsLabel> <InputsLabel for="password">Password</InputsLabel>
<InputsError v-if="errorMessage">{{ errorMessage }}</InputsError> <InputsError v-if="errorMessage">{{ errorMessage }}</InputsError>
</InputsLabelAndError> </InputsLabelAndError>
<InputsPassword id="password" placeholder="hunter2" required v-bind="field" <InputsPassword id="password" placeholder="hunter2" required v-bind="field"
:disabled="isLoading" :is-invalid="!!errorMessage" /> :disabled="isLoading" :is-invalid="!!errorMessage" :show-strength="true"
autocomplete="new-password" />
</InputsField> </InputsField>
</VeeField> </VeeField>
<VeeField name="password-confirm" as="div" v-slot="{ errorMessage, field }" validate-on-change> <VeeField name="password-confirm" v-slot="{ errorMessage, field }" validate-on-change>
<InputsField> <InputsField>
<InputsLabelAndError> <InputsLabelAndError>
<InputsLabel for="password-confirm">Confirm password</InputsLabel> <InputsLabel for="password-confirm">Confirm password</InputsLabel>
<InputsError v-if="errorMessage">{{ errorMessage }}</InputsError> <InputsError v-if="errorMessage">{{ errorMessage }}</InputsError>
</InputsLabelAndError> </InputsLabelAndError>
<InputsPassword id="password-confirm" placeholder="hunter2" required v-bind="field" <InputsPassword id="password-confirm" placeholder="hunter2" required v-bind="field"
:disabled="isLoading" :is-invalid="!!errorMessage" /> :disabled="isLoading" :is-invalid="!!errorMessage" autocomplete="new-password" />
</InputsField> </InputsField>
</VeeField> </VeeField>
<VeeField name="tos" v-slot="{ errorMessage, field }" validate-on-change>
<InputsField>
<div class="flex flex-row gap-x-2 items-center">
<InputsCheckbox :checked="true" id="tos" required :disabled="true" v-bind="field" />
<InputsLabel for="tos" class="!text-gray-200">
I agree to the Terms of Service
</InputsLabel>
</div>
<InputsError v-if="errorMessage">{{ errorMessage }}</InputsError>
</InputsField>
</VeeField>
<Collapsible.Root>
<Collapsible.Trigger class="w-full">
<ButtonsSecondary type="button" class="w-full">View Terms of Service</ButtonsSecondary>
</Collapsible.Trigger>
<Collapsible.Content
class="prose prose-invert prose-sm p-4 ring-1 ring-white/10 bg-dark-700 rounded mt-3">
<div v-html="tos?.content"></div>
</Collapsible.Content>
</Collapsible.Root>
<p class="text-xs font-semibold text-gray-300"> <p class="text-xs font-semibold text-gray-300">
Passwords are stored securely and hashed. We do not store your password in plain text. Passwords are stored securely and hashed. We do not store your password in plain text.
Administrators Administrators
@ -40,7 +86,7 @@
</p> </p>
<ButtonsPrimary type="submit" class="w-full" :disabled="isLoading">{{ isLoading ? "Registering..." : <ButtonsPrimary type="submit" class="w-full" :disabled="isLoading">{{ isLoading ? "Registering..." :
"Register" }}</ButtonsPrimary> "Register" }}</ButtonsPrimary>
</VeeForm> </VeeForm>
</div> </div>
<div v-else> <div v-else>
@ -55,35 +101,43 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Collapsible } from "@ark-ui/vue";
import type { ResponseError } from "@lysand-org/client";
import { toTypedSchema } from "@vee-validate/zod"; import { toTypedSchema } from "@vee-validate/zod";
import { z } from "zod"; import { z } from "zod";
// TODO: Add instance TOS link
const schema = toTypedSchema( const schema = toTypedSchema(
z z
.object({ .object({
email: z.string().email(),
password: z.string().min(3).max(255), password: z.string().min(3).max(255),
"password-confirm": z.string().min(3).max(255),
username: z
.string()
.min(3)
.regex(
/^[a-z0-9_]+$/,
"Must be lowercase letters, numbers, or underscores",
),
reason: z.string().optional(),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
/* if (data.password !== data.password2) { if (data.password !== data["password-confirm"]) {
ctx.addIssue({ ctx.addIssue({
path: [...ctx.path, "password2"], path: [...ctx.path, "password-confirm"],
code: "custom", code: "custom",
message: "Passwords do not match", message: "Passwords do not match",
}); });
} */ }
return {}; return {};
}), }),
); );
const client = useClient(); const client = useClient();
const instance = useInstance(); const tos = useTos(client);
const errors = ref<{ const errors = ref<{
[key: string]: { error?: string;
error: string;
description: string;
}[];
}>({}); }>({});
const isLoading = ref(false); const isLoading = ref(false);
@ -104,16 +158,48 @@ const register = (result: {
"en", "en",
result.reason || "Empty reason", result.reason || "Empty reason",
) )
.then(async (res) => { .then(async () => {
navigateTo("/register/success"); navigateTo("/register/success");
}) })
.catch(async (err) => { .catch(async (e) => {
const error = e as ResponseError<{
error: string;
}>;
// @ts-ignore // @ts-ignore
errors.value = error.response?.data || {}; errors.value = error.response.data || {};
console.error(err);
}) })
.finally(() => { .finally(() => {
isLoading.value = false; isLoading.value = false;
}); });
}; };
</script> </script>
<style>
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--height);
}
}
@keyframes slideUp {
from {
height: var(--height);
}
to {
height: 0;
}
}
[data-scope='collapsible'][data-part='content'][data-state='open'] {
animation: slideDown 250ms;
}
[data-scope='collapsible'][data-part='content'][data-state='closed'] {
animation: slideUp 200ms;
}
</style>

22
utils/passwords.ts Normal file
View file

@ -0,0 +1,22 @@
/**
* Get the strength of a password
* @param password The password to check
* @returns Number from 0 to Infinity representing the strength of the password
*/
export const passwordStrength = (password: string): number => {
const length = password.length;
const specialChars = password.match(/[^A-Za-z0-9]/g)?.length || 0;
const numbers = password.match(/[0-9]/g)?.length || 0;
const upperCase = password.match(/[A-Z]/g)?.length || 0;
const lowerCase = password.match(/[a-z]/g)?.length || 0;
// Calculate the strength of the password
return (
(length * 4 +
specialChars * 6 +
numbers * 4 +
upperCase * 2 +
lowerCase * 2) /
16
);
};