From cdafdab4e6cfb4b4b732f39ed6801b6e3ea4c8a0 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Wed, 20 Dec 2023 11:06:48 -0500 Subject: [PATCH] Fixed user storage. --- src/stores/user.ts | 233 ++++++++++++++----------------- src/stores/userRepository.ts | 64 +++++++++ src/types/api.ts | 4 + src/views/account/SignInView.vue | 12 +- 4 files changed, 177 insertions(+), 136 deletions(-) create mode 100644 src/stores/userRepository.ts create mode 100644 src/types/api.ts diff --git a/src/stores/user.ts b/src/stores/user.ts index 8675025..96aa6ef 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -1,8 +1,9 @@ import { defineStore } from "pinia"; import { nanoid } from "nanoid"; -import { ref } from "vue"; +import UserRepository from "./userRepository"; import type { Actor } from "@/types/aggregate"; +import type { ErrorDetail } from "@/types/api"; import type { RegisterPayload, SignInPayload } from "@/types/account"; import type { User } from "@/types/users"; @@ -35,148 +36,124 @@ function toActor(user: User): Actor { }; } -export const useUserStore = defineStore( - "user", - () => { - const emailIndex = ref>(new Map()); - const passwordHashes = ref>(new Map()); - const usernameIndex = ref>(new Map()); - const users = ref>(new Map()); - - function create(payload: RegisterPayload): void { - // Ensure username unicity - const username: string = payload.username.trim(); - const usernameNormalized: string = username.toUpperCase(); - if (usernameIndex.value.has(usernameNormalized)) { - return; - } +export const useUserStore = defineStore("user", () => { + function create(payload: RegisterPayload): void { + // Initialize storage + const users = new UserRepository(localStorage); - // Ensure email address unicity - const emailAddress: string | undefined = payload.emailAddress?.trim(); - const emailAddressNormalized: string | undefined = emailAddress?.toUpperCase(); - if (emailAddressNormalized && emailIndex.value.has(emailAddressNormalized)) { - return; - } + // Ensure username unicity + const username: string = payload.username.trim(); + if (users.findByUsername(username)) { + return; + } - // Prepare user information - const firstName: string | undefined = payload.firstName?.trim(); - const lastName: string | undefined = payload.lastName?.trim(); - const fullName: string | undefined = buildFullName(firstName, lastName); - - // Generate an unique identifier and a timestamp - const id: string = nanoid(); - const now: string = new Date().toISOString(); - - // Create an actor - const actor: Actor = { - id, - type: "User", - isDeleted: false, - displayName: fullName ?? username, - emailAddress, - }; - - // Create the user - const user: User = { - id, - version: 1, - createdBy: actor, - createdOn: now, - updatedBy: actor, - updatedOn: now, - username, - hasPassword: Boolean(payload.password), - isDisabled: false, - isConfirmed: false, - firstName, - lastName, - fullName, - }; - if (payload.password) { - user.passwordChangedBy = actor; - user.passwordChangedOn = now; - } - if (emailAddress) { - user.email = { isVerified: false, address: emailAddress }; - } + // Ensure email address unicity + const emailAddress: string | undefined = payload.emailAddress?.trim(); + if (emailAddress && users.findByEmail(emailAddress)) { + return; + } - // Save the user - users.value.set(id, user); - usernameIndex.value.set(usernameNormalized, id); - if (emailAddressNormalized) { - emailIndex.value.set(emailAddressNormalized, id); - } - if (payload.password) { - passwordHashes.value.set(id, hash(payload.password)); - } + // Prepare user information + const firstName: string | undefined = payload.firstName?.trim(); + const lastName: string | undefined = payload.lastName?.trim(); + const fullName: string | undefined = buildFullName(firstName, lastName); + + // Generate an unique identifier and a timestamp + const id: string = nanoid(); + const now: string = new Date().toISOString(); + + // Create an actor + const actor: Actor = { + id, + type: "User", + isDeleted: false, + displayName: fullName ?? username, + emailAddress, + }; + + // Create the user + const user: User = { + id, + version: 1, + createdBy: actor, + createdOn: now, + updatedBy: actor, + updatedOn: now, + username, + hasPassword: Boolean(payload.password), + isDisabled: false, + isConfirmed: false, + firstName, + lastName, + fullName, + }; + if (payload.password) { + user.passwordChangedBy = actor; + user.passwordChangedOn = now; + } + if (emailAddress) { + user.email = { isVerified: false, address: emailAddress }; } - function signIn(payload: SignInPayload): Actor { - // Try finding the user by username - const usernameNormalized = payload.username.trim().toUpperCase(); - let id: string | undefined = usernameIndex.value.get(usernameNormalized); + // Save the user + const passwordHash: string | undefined = payload.password ? hash(payload.password) : undefined; + users.save(user, passwordHash); + } - // Try finding the user by email address - if (!id) { - id = emailIndex.value.get(usernameNormalized); - } + function signIn(payload: SignInPayload): Actor { + // Initialize storage + const users = new UserRepository(localStorage); - // Checking credentials - if (!id) { - throw "INVALID_CREDENTIALS"; // TODO(fpion): use error object - } - const user: User | undefined = users.value.get(id); - if (!user || user.isDisabled) { - throw "INVALID_CREDENTIALS"; // TODO(fpion): use error object - } - if (user.hasPassword) { - const password: string | undefined = passwordHashes.value.get(id); - if (!password || !payload.password || hash(payload.password) !== password) { - throw "INVALID_CREDENTIALS"; // TODO(fpion): use error object - } + // Try finding the user by username or email address + const user: User | undefined = users.findByUsername(payload.username) ?? users.findByEmail(payload.username); + + // Checking credentials + if (!user || user.isDisabled) { + throw { code: "InvalidCredentials" } as ErrorDetail; + } + if (user.hasPassword) { + const password: string | undefined = users.findPassword(user); + if (!password || !payload.password || hash(payload.password) !== password) { + throw { code: "InvalidCredentials" } as ErrorDetail; } + } - // Authenticate the user - user.authenticatedOn = new Date().toISOString(); - users.value.set(id, user); + // Authenticate the user + user.authenticatedOn = new Date().toISOString(); + users.save(user); - return toActor(user); - } + return toActor(user); + } - function verifyEmail(emailAddress: string): Actor | undefined { - // Find the user identifier using the email address - const emailAddressNormalized: string = emailAddress.trim().toUpperCase(); - const id: string | undefined = emailIndex.value.get(emailAddressNormalized); - if (!id) { - return; - } + function verifyEmail(emailAddress: string): Actor | undefined { + // Initialize storage + const users = new UserRepository(localStorage); - // Find the user - const user: User | undefined = users.value.get(id); - if (!user?.email) { - return; - } + // Find the user by the email address + const user: User | undefined = users.findByEmail(emailAddress); + if (!user?.email) { + return; + } - // Check the user email is not already verified - if (user.email.isVerified) { - return; - } + // Check the user email is not already verified + if (user.email.isVerified) { + return; + } - // Create an actor from the user - const actor: Actor = toActor(user); + // Create an actor from the user + const actor: Actor = toActor(user); - // Verify the user email - user.email.isVerified = true; - user.email.verifiedBy = actor; - user.email.verifiedOn = new Date().toISOString(); + // Verify the user email + user.email.isVerified = true; + user.email.verifiedBy = actor; + user.email.verifiedOn = new Date().toISOString(); - // Confirm the user - user.isConfirmed = true; + // Confirm the user + user.isConfirmed = true; + users.save(user); - return actor; - } + return actor; + } - return { create, signIn, verifyEmail }; - }, - { persist: true }, // TODO(fpion): does not seem to work because Map is not serializable -); + return { create, signIn, verifyEmail }; +}); diff --git a/src/stores/userRepository.ts b/src/stores/userRepository.ts new file mode 100644 index 0000000..ef20421 --- /dev/null +++ b/src/stores/userRepository.ts @@ -0,0 +1,64 @@ +import type { User } from "@/types/users"; + +function getEmailKey(address: string): string { + return `user:email:${address.trim().toLowerCase()}`; +} +function getIdKey(id: string): string { + return `user:id:${id.trim()}`; +} +function getPasswordKey(user: User): string { + return `user:id:${user.id.trim()}:password`; +} +function getUsernameKey(username: string): string { + return `user:username:${username.trim().toLowerCase()}`; +} + +class UserRepository { + storage: Storage; + + constructor(storage: Storage) { + this.storage = storage; + } + + find(id: string): User | undefined { + const serialized: string | null = this.storage.getItem(getIdKey(id)); + if (!serialized) { + return; + } + return JSON.parse(serialized) as User; + } + + findByEmail(address: string): User | undefined { + const id: string | null = this.storage.getItem(getEmailKey(address)); + if (!id) { + return; + } + return this.find(id); + } + + findByUsername(username: string): User | undefined { + const id: string | null = this.storage.getItem(getUsernameKey(username)); + if (!id) { + return; + } + return this.find(id); + } + + findPassword(user: User): string | undefined { + return this.storage.getItem(getPasswordKey(user)) ?? undefined; + } + + save(user: User, passwordHash?: string): void { + this.storage.setItem(getIdKey(user.id), JSON.stringify(user)); + this.storage.setItem(getUsernameKey(user.username), user.id); + if (user.email) { + this.storage.setItem(getEmailKey(user.email?.address), user.id); + } + + if (passwordHash) { + this.storage.setItem(getPasswordKey(user), passwordHash); + } + } +} + +export default UserRepository; diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..ae7d33a --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,4 @@ +export type ErrorDetail = { + code: string; + message?: string; +}; diff --git a/src/views/account/SignInView.vue b/src/views/account/SignInView.vue index 01b5391..9f21eef 100644 --- a/src/views/account/SignInView.vue +++ b/src/views/account/SignInView.vue @@ -4,6 +4,7 @@ import { ref } from "vue"; import { useRoute, useRouter } from "vue-router"; import type { Actor } from "@/types/aggregate"; +import type { ErrorDetail } from "@/types/api"; import type { SignInPayload } from "@/types/account"; import { signIn } from "@/api/account"; import { useAccountStore } from "@/stores/account"; @@ -25,8 +26,9 @@ async function submit(): Promise { account.signIn(actor); const redirect = route.query.redirect as string | undefined; router.push(redirect ?? { name: "Profile" }); - } catch (e) { - if (e === "INVALID_CREDENTIALS") { + } catch (e: unknown) { + const { code } = e as ErrorDetail; + if (code === "InvalidCredentials") { invalidCredentials.value = true; } else { console.error(e); // TODO(fpion): error handling @@ -36,12 +38,6 @@ async function submit(): Promise { } } } - -import { useUserStore } from "@/stores/user"; -const users = useUserStore(); -users.create({ - username: "fpion", -});