Creating APIs for changing user password and calling it in change password. Cleaned up CSS layout styles and got settings nav correct.

This commit is contained in:
Bradley Shellnut 2024-09-09 19:25:16 -07:00
parent 2eee00a20d
commit 3b33880166
31 changed files with 350 additions and 357 deletions

View file

@ -76,7 +76,7 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@fontsource/fira-mono": "^5.0.14", "@fontsource/fira-mono": "^5.0.15",
"@hono/swagger-ui": "^0.4.1", "@hono/swagger-ui": "^0.4.1",
"@hono/zod-openapi": "^0.15.3", "@hono/zod-openapi": "^0.15.3",
"@hono/zod-validator": "^0.2.2", "@hono/zod-validator": "^0.2.2",

View file

@ -9,8 +9,8 @@ importers:
.: .:
dependencies: dependencies:
'@fontsource/fira-mono': '@fontsource/fira-mono':
specifier: ^5.0.14 specifier: ^5.0.15
version: 5.0.14 version: 5.0.15
'@hono/swagger-ui': '@hono/swagger-ui':
specifier: ^0.4.1 specifier: ^0.4.1
version: 0.4.1(hono@4.5.11) version: 0.4.1(hono@4.5.11)
@ -1245,8 +1245,8 @@ packages:
'@floating-ui/utils@0.2.7': '@floating-ui/utils@0.2.7':
resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==} resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==}
'@fontsource/fira-mono@5.0.14': '@fontsource/fira-mono@5.0.15':
resolution: {integrity: sha512-4IKa+cuHipk/vr2frgZh4pyR2XcoQk/j3zmMlo8uuAGUB3IPLpQlgN6Qm5d3RfRZ7dXGlTn/PWiAJeU8bkmD4w==} resolution: {integrity: sha512-wc3TpF2GBbtFDKNbb444BrC3mEKuoPLITSYCKweNIrqBvAalIfJGloY/MVrmSGaMNgaAKUpdgy4eAWPLkUVzaA==}
'@gcornut/valibot-json-schema@0.31.0': '@gcornut/valibot-json-schema@0.31.0':
resolution: {integrity: sha512-3xGptCurm23e7nuPQkdrE5rEs1FeTPHhAUsBuwwqG4/YeZLwJOoYZv+fmsppUEfo5y9lzUwNQrNqLS/q7HMc7g==} resolution: {integrity: sha512-3xGptCurm23e7nuPQkdrE5rEs1FeTPHhAUsBuwwqG4/YeZLwJOoYZv+fmsppUEfo5y9lzUwNQrNqLS/q7HMc7g==}
@ -5489,7 +5489,7 @@ snapshots:
'@floating-ui/utils@0.2.7': {} '@floating-ui/utils@0.2.7': {}
'@fontsource/fira-mono@5.0.14': {} '@fontsource/fira-mono@5.0.15': {}
'@gcornut/valibot-json-schema@0.31.0': '@gcornut/valibot-json-schema@0.31.0':
dependencies: dependencies:

View file

@ -4,15 +4,10 @@ import { invalidateAll } from '$app/navigation'
import Logo from '$components/logo.svelte' import Logo from '$components/logo.svelte'
import * as Avatar from '$components/ui/avatar' import * as Avatar from '$components/ui/avatar'
import * as DropdownMenu from '$components/ui/dropdown-menu' import * as DropdownMenu from '$components/ui/dropdown-menu'
import type { Users } from '$db/schema' import { ListChecks, ListTodo, LogOut, Settings } from 'lucide-svelte'
import { ListChecks, ListTodo, LogOut, User } from 'lucide-svelte'
import toast from 'svelte-french-toast' import toast from 'svelte-french-toast'
type HeaderProps = { let { user = null } = $props()
user: Users | null
}
let { user = null }: HeaderProps = $props()
let avatar: string = $derived(user?.username?.slice(0, 1).toUpperCase() || ':)') let avatar: string = $derived(user?.username?.slice(0, 1).toUpperCase() || ':)')
</script> </script>
@ -28,39 +23,48 @@ let avatar: string = $derived(user?.username?.slice(0, 1).toUpperCase() || ':)')
</div> </div>
<nav> <nav>
{#if user} {#if user}
<DropdownMenu.Root> {@render userDropdown()}
<DropdownMenu.Trigger> {:else}
<Avatar.Root asChild> <a href="/login"> <span class="flex-auto">Login</span></a>
<Avatar.Fallback class="text-3xl font-medium text-magnum-700 h-16 w-16 bg-neutral-100"> <a href="/signup"> <span class="flex-auto">Sign Up</span></a>
{avatar} {/if}
</Avatar.Fallback> </nav>
</Avatar.Root> </header>
</DropdownMenu.Trigger>
<DropdownMenu.Content> {#snippet userDropdown()}
<DropdownMenu.Group> <DropdownMenu.Root>
<DropdownMenu.Label>Account</DropdownMenu.Label> <DropdownMenu.Trigger>
<DropdownMenu.Separator /> <Avatar.Root asChild>
<a href="/settings"> <Avatar.Fallback class="text-3xl font-medium text-magnum-700 h-16 w-16 bg-neutral-100">
<DropdownMenu.Item> {avatar}
<User class="mr-2 h-4 w-4" /> </Avatar.Fallback>
<span>Settings</span> </Avatar.Root>
</DropdownMenu.Item> </DropdownMenu.Trigger>
</a> <DropdownMenu.Content>
<a href="/collections"> <DropdownMenu.Group>
<DropdownMenu.Item> <DropdownMenu.Label>Account</DropdownMenu.Label>
<ListChecks class="mr-2 h-4 w-4" /> <DropdownMenu.Separator />
<span>Collections</span> <a href="/settings">
</DropdownMenu.Item> <DropdownMenu.Item>
</a> <Settings class="mr-2 h-4 w-4" />
<a href="/wishlists"> <span>Settings</span>
<DropdownMenu.Item> </DropdownMenu.Item>
<ListTodo class="mr-2 h-4 w-4" /> </a>
<span>Wishlists</span> <a href="/collections">
</DropdownMenu.Item> <DropdownMenu.Item>
</a> <ListChecks class="mr-2 h-4 w-4" />
<DropdownMenu.Item> <span>Collections</span>
<form </DropdownMenu.Item>
use:enhance={() => { </a>
<a href="/wishlists">
<DropdownMenu.Item>
<ListTodo class="mr-2 h-4 w-4" />
<span>Wishlists</span>
</DropdownMenu.Item>
</a>
<DropdownMenu.Item>
<form
use:enhance={() => {
return async ({ result }) => { return async ({ result }) => {
console.log(result); console.log(result);
if (result.type === 'success' || result.type === 'redirect') { if (result.type === 'success' || result.type === 'redirect') {
@ -76,26 +80,21 @@ let avatar: string = $derived(user?.username?.slice(0, 1).toUpperCase() || ':)')
await applyAction(result); await applyAction(result);
}; };
}} }}
action="/logout" action="/logout"
method="POST" method="POST"
> >
<button type="submit"> <button type="submit">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<LogOut class="mr-2 h-4 w-4" /> <LogOut class="mr-2 h-4 w-4" />
<span>Sign out</span> <span>Sign out</span>
</div> </div>
</button> </button>
</form> </form>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Group> </DropdownMenu.Group>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
{:else} {/snippet}
<a href="/login"> <span class="flex-auto">Login</span></a>
<a href="/signup"> <span class="flex-auto">Sign Up</span></a>
{/if}
</nav>
</header>
<style lang="postcss"> <style lang="postcss">
header { header {

View file

@ -1,77 +0,0 @@
<script lang="ts">
import { page } from '$app/stores'
import type { Route } from '$lib/types'
let { children, routes }: { children: unknown; routes: Route[] } = $props()
</script>
<div class="mx-auto grid w-full max-w-6xl gap-2">
<h1 class="text-3xl font-semibold">Settings</h1>
</div>
<div class="security-nav">
<nav>
<ul>
{#each routes as { href, label }}
<li>
<a href={href} class:active={$page.url.pathname.includes(href)}>
{label}
</a>
</li>
{/each}
</ul>
</nav>
<div class="security-nav-content">
{@render children()}
</div>
</div>
<style lang="postcss">
.security-nav {
display: flex;
nav {
width: 16rem;
position: sticky;
top: 0;
left: 0;
background-color: #fff;
padding: 1rem;
border-right: 1px solid #ddd;
height: 100vh;
ul {
list-style-type: none;
padding: 0;
margin: 0;
}
li {
margin-bottom: 0.5rem;
}
a {
text-decoration: none;
color: #337ab7;
display: block;
padding: 0.5rem;
border-radius: 0.25rem;
&:hover {
background-color: #f8f9fa;
}
&.active {
color: var(--color-link-hover);
font-weight: 600;
background-color: #e9ecef;
}
}
}
}
.security-nav-content {
flex: 1;
padding: 1rem;
}
</style>

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements"; import type { HTMLInputAttributes } from "svelte/elements";
import type { InputEvents } from "./index.js"; import type { InputEvents } from "./index.js";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils/ui.js";
type $$Props = HTMLInputAttributes; type $$Props = HTMLInputAttributes;
type $$Events = InputEvents; type $$Events = InputEvents;

View file

@ -2,12 +2,12 @@ import { type VariantProps, tv } from "tailwind-variants";
import Root from "./toggle.svelte"; import Root from "./toggle.svelte";
export const toggleVariants = tv({ export const toggleVariants = tv({
base: "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", base: "ring-offset-background hover:bg-muted hover:text-muted-foreground focus-visible:ring-ring data-[state=on]:bg-accent data-[state=on]:text-accent-foreground inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
variants: { variants: {
variant: { variant: {
default: "bg-transparent", default: "bg-transparent",
outline: outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", "border-input hover:bg-accent hover:text-accent-foreground border bg-transparent",
}, },
size: { size: {
default: "h-10 px-3", default: "h-10 px-3",

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Toggle as TogglePrimitive } from "bits-ui"; import { Toggle as TogglePrimitive } from "bits-ui";
import { type Size, type Variant, toggleVariants } from "./index.js"; import { type Size, type Variant, toggleVariants } from "./index.js";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils/ui.js";
type $$Props = TogglePrimitive.Props & { type $$Props = TogglePrimitive.Props & {
variant?: Variant; variant?: Variant;

View file

@ -1,36 +1,37 @@
<script lang="ts"> <script lang="ts">
import { navigating } from '$app/stores'; import { onNavigate } from '$app/navigation'
import { onNavigate } from '$app/navigation'; import { navigating } from '$app/stores'
let visible = false;
let progress = 0; let visible = false
let load_durations: number[] = []; let progress = 0
$: average_load = load_durations.reduce((a, b) => a + b, 0) / load_durations.length; let load_durations: number[] = []
const increment = 1; $: average_load = load_durations.reduce((a, b) => a + b, 0) / load_durations.length
onNavigate((navigation) => { const increment = 1
const typical_load_time = average_load || 200; //ms onNavigate((navigation) => {
const frequency = typical_load_time / 100; const typical_load_time = average_load || 200 //ms
let start = performance.now(); const frequency = typical_load_time / 100
// Start the progress bar let start = performance.now()
visible = true; // Start the progress bar
progress = 0; visible = true
const interval = setInterval(() => { progress = 0
// Increment the progress bar const interval = setInterval(() => {
progress += increment; // Increment the progress bar
}, frequency); progress += increment
// Resolve the promise when the page is done loading }, frequency)
$navigating?.complete.then(() => { // Resolve the promise when the page is done loading
progress = 100; // Fill out the progress bar $navigating?.complete.then(() => {
clearInterval(interval); progress = 100 // Fill out the progress bar
// after 100 ms hide the progress bar clearInterval(interval)
setTimeout(() => { // after 100 ms hide the progress bar
visible = false; setTimeout(() => {
}, 500); visible = false
// Log how long that one took }, 500)
const end = performance.now(); // Log how long that one took
const duration = end - start; const end = performance.now()
load_durations = [...load_durations, duration]; const duration = end - start
}); load_durations = [...load_durations, duration]
}); })
})
</script> </script>
<div class="progress" class:visible style:--progress={progress}> <div class="progress" class:visible style:--progress={progress}>

View file

@ -34,5 +34,3 @@ export const config: Config = {
max: env.DB_MIGRATING || env.DB_SEEDING ? 1 : undefined, max: env.DB_MIGRATING || env.DB_SEEDING ? 1 : undefined,
}, },
} }
console.log('config', config)

View file

@ -1,26 +1,26 @@
import { StatusCodes } from '$lib/constants/status-codes'; import { StatusCodes } from '$lib/constants/status-codes'
import { HTTPException } from 'hono/http-exception'; import { HTTPException } from 'hono/http-exception'
export function TooManyRequests(message: string = 'Too many requests') { export function TooManyRequests(message = 'Too many requests') {
return new HTTPException(StatusCodes.TOO_MANY_REQUESTS, { message }); return new HTTPException(StatusCodes.TOO_MANY_REQUESTS, { message })
} }
export function Forbidden(message: string = 'Forbidden') { export function Forbidden(message = 'Forbidden') {
return new HTTPException(StatusCodes.FORBIDDEN, { message }); return new HTTPException(StatusCodes.FORBIDDEN, { message })
} }
export function Unauthorized(message: string = 'Unauthorized') { export function Unauthorized(message = 'Unauthorized') {
return new HTTPException(StatusCodes.UNAUTHORIZED, { message }); return new HTTPException(StatusCodes.UNAUTHORIZED, { message })
} }
export function NotFound(message: string = 'Not Found') { export function NotFound(message = 'Not Found') {
return new HTTPException(StatusCodes.NOT_FOUND, { message }); return new HTTPException(StatusCodes.NOT_FOUND, { message })
} }
export function BadRequest(message: string = 'Bad Request') { export function BadRequest(message = 'Bad Request') {
return new HTTPException(StatusCodes.BAD_REQUEST, { message }); return new HTTPException(StatusCodes.BAD_REQUEST, { message })
} }
export function InternalError(message: string = 'Internal Error') { export function InternalError(message = 'Internal Error') {
return new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { message }); return new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { message })
} }

View file

@ -2,7 +2,7 @@ import 'reflect-metadata'
import { Controller } from '$lib/server/api/common/types/controller' import { Controller } from '$lib/server/api/common/types/controller'
import { CollectionsService } from '$lib/server/api/services/collections.service' import { CollectionsService } from '$lib/server/api/services/collections.service'
import { inject, injectable } from 'tsyringe' import { inject, injectable } from 'tsyringe'
import { requireAuth } from '../middleware/auth.middleware' import { requireAuth } from '../middleware/require-auth.middleware'
@injectable() @injectable()
export class CollectionController extends Controller { export class CollectionController extends Controller {

View file

@ -1,20 +1,23 @@
import { StatusCodes } from '$lib/constants/status-codes' import { StatusCodes } from '$lib/constants/status-codes'
import { Controller } from '$lib/server/api/common/types/controller' import { Controller } from '$lib/server/api/common/types/controller'
import { changePasswordDto } from '$lib/server/api/dtos/change-password.dto'
import { updateEmailDto } from '$lib/server/api/dtos/update-email.dto' import { updateEmailDto } from '$lib/server/api/dtos/update-email.dto'
import { updateProfileDto } from '$lib/server/api/dtos/update-profile.dto' import { updateProfileDto } from '$lib/server/api/dtos/update-profile.dto'
import { verifyPasswordDto } from '$lib/server/api/dtos/verify-password.dto' import { verifyPasswordDto } from '$lib/server/api/dtos/verify-password.dto'
import { limiter } from '$lib/server/api/middleware/rate-limiter.middleware' import { limiter } from '$lib/server/api/middleware/rate-limiter.middleware'
import { IamService } from '$lib/server/api/services/iam.service' import { IamService } from '$lib/server/api/services/iam.service'
import { LoginRequestsService } from '$lib/server/api/services/loginrequest.service'
import { LuciaService } from '$lib/server/api/services/lucia.service' import { LuciaService } from '$lib/server/api/services/lucia.service'
import { zValidator } from '@hono/zod-validator' import { zValidator } from '@hono/zod-validator'
import { setCookie } from 'hono/cookie' import { setCookie } from 'hono/cookie'
import { inject, injectable } from 'tsyringe' import { inject, injectable } from 'tsyringe'
import { requireAuth } from '../middleware/auth.middleware' import { requireAuth } from '../middleware/require-auth.middleware'
@injectable() @injectable()
export class IamController extends Controller { export class IamController extends Controller {
constructor( constructor(
@inject(IamService) private readonly iamService: IamService, @inject(IamService) private readonly iamService: IamService,
@inject(LoginRequestsService) private readonly loginRequestService: LoginRequestsService,
@inject(LuciaService) private luciaService: LuciaService, @inject(LuciaService) private luciaService: LuciaService,
) { ) {
super() super()
@ -45,6 +48,32 @@ export class IamController extends Controller {
} }
return c.json({}, StatusCodes.OK) return c.json({}, StatusCodes.OK)
}) })
.put('/update/password', requireAuth, zValidator('json', changePasswordDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const user = c.var.user
const { password, confirm_password } = c.req.valid('json')
if (password !== confirm_password) {
return c.json('Passwords do not match', StatusCodes.BAD_REQUEST)
}
try {
await this.iamService.updatePassword(user.id, { password, confirm_password })
await this.luciaService.lucia.invalidateUserSessions(user.id)
await this.loginRequestService.createUserSession(user.id, c.req, undefined)
const sessionCookie = this.luciaService.lucia.createBlankSessionCookie()
setCookie(c, sessionCookie.name, sessionCookie.value, {
path: sessionCookie.attributes.path,
maxAge: sessionCookie.attributes.maxAge,
domain: sessionCookie.attributes.domain,
sameSite: sessionCookie.attributes.sameSite as any,
secure: sessionCookie.attributes.secure,
httpOnly: sessionCookie.attributes.httpOnly,
expires: sessionCookie.attributes.expires,
})
return c.json({ status: 'success' })
} catch (error) {
console.error('Error updating password', error)
return c.json('Error updating password', StatusCodes.BAD_REQUEST)
}
})
.post('/update/email', requireAuth, zValidator('json', updateEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => { .post('/update/email', requireAuth, zValidator('json', updateEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const user = c.var.user const user = c.var.user
const { email } = c.req.valid('json') const { email } = c.req.valid('json')

View file

@ -8,7 +8,7 @@ import { UsersService } from '$lib/server/api/services/users.service'
import { zValidator } from '@hono/zod-validator' import { zValidator } from '@hono/zod-validator'
import { inject, injectable } from 'tsyringe' import { inject, injectable } from 'tsyringe'
import { CredentialsType } from '../databases/tables' import { CredentialsType } from '../databases/tables'
import { requireAuth } from '../middleware/auth.middleware' import { requireAuth } from '../middleware/require-auth.middleware'
@injectable() @injectable()
export class MfaController extends Controller { export class MfaController extends Controller {

View file

@ -2,7 +2,7 @@ import 'reflect-metadata'
import { Controller } from '$lib/server/api/common/types/controller' import { Controller } from '$lib/server/api/common/types/controller'
import { UsersService } from '$lib/server/api/services/users.service' import { UsersService } from '$lib/server/api/services/users.service'
import { inject, injectable } from 'tsyringe' import { inject, injectable } from 'tsyringe'
import { requireAuth } from '../middleware/auth.middleware' import { requireAuth } from '../middleware/require-auth.middleware'
@injectable() @injectable()
export class UserController extends Controller { export class UserController extends Controller {

View file

@ -2,7 +2,7 @@ import 'reflect-metadata'
import { Controller } from '$lib/server/api/common/types/controller' import { Controller } from '$lib/server/api/common/types/controller'
import { WishlistsService } from '$lib/server/api/services/wishlists.service' import { WishlistsService } from '$lib/server/api/services/wishlists.service'
import { inject, injectable } from 'tsyringe' import { inject, injectable } from 'tsyringe'
import { requireAuth } from '../middleware/auth.middleware' import { requireAuth } from '../middleware/require-auth.middleware'
@injectable() @injectable()
export class WishlistController extends Controller { export class WishlistController extends Controller {

View file

@ -0,0 +1,17 @@
import { refinePasswords } from '$lib/validations/account'
import { z } from 'zod'
export const changePasswordDto = z
.object({
password: z.string({ required_error: 'Password is required' }).trim(),
confirm_password: z
.string({ required_error: 'Confirm Password is required' })
.trim()
.min(8, { message: 'Must be at least 8 characters' })
.max(128, { message: 'Must be less than 128 characters' }),
})
.superRefine(({ confirm_password, password }, ctx) => {
return refinePasswords(confirm_password, password, ctx)
})
export type ChangePasswordDto = z.infer<typeof changePasswordDto>

View file

@ -1,23 +1,14 @@
import { z } from "zod"; import { z } from 'zod'
export const updateProfileDto = z.object({ export const updateProfileDto = z.object({
firstName: z firstName: z
.string() .string()
.trim() .trim()
.min(3, {message: 'Must be at least 3 characters'}) .min(3, { message: 'Must be at least 3 characters' })
.max(50, {message: 'Must be less than 50 characters'}) .max(50, { message: 'Must be less than 50 characters' })
.optional(), .optional(),
lastName: z lastName: z.string().trim().min(3, { message: 'Must be at least 3 characters' }).max(50, { message: 'Must be less than 50 characters' }).optional(),
.string() username: z.string().trim().min(3, { message: 'Must be at least 3 characters' }).max(50, { message: 'Must be less than 50 characters' }),
.trim() })
.min(3, {message: 'Must be at least 3 characters'})
.max(50, {message: 'Must be less than 50 characters'})
.optional(),
username: z
.string()
.trim()
.min(3, {message: 'Must be at least 3 characters'})
.max(50, {message: 'Must be less than 50 characters'})
});
export type UpdateProfileDto = z.infer<typeof updateProfileDto>; export type UpdateProfileDto = z.infer<typeof updateProfileDto>

View file

@ -10,11 +10,11 @@ export class AuthCleanupJobs {
this.queue = this.jobsService.createQueue('test') this.queue = this.jobsService.createQueue('test')
/* ---------------------------- Register Workers ---------------------------- */ /* ---------------------------- Register Workers ---------------------------- */
this.worker().then((r) => console.log('auth-cleanup job worker started')) this.worker().then(() => console.log('auth-cleanup job worker started'))
} }
async deleteStaleEmailVerificationRequests() { async deleteStaleEmailVerificationRequests() {
await this.queue.add('delete_stale_email_verifiactions', null, { await this.queue.add('delete_stale_email_verifications', null, {
repeat: { repeat: {
pattern: '0 0 * * 0', // Runs once a week at midnight on Sunday pattern: '0 0 * * 0', // Runs once a week at midnight on Sunday
}, },
@ -31,7 +31,7 @@ export class AuthCleanupJobs {
private async worker() { private async worker() {
return this.jobsService.createWorker(this.queue.name, async (job) => { return this.jobsService.createWorker(this.queue.name, async (job) => {
if (job.name === 'delete_stale_email_verifiactions') { if (job.name === 'delete_stale_email_verifications') {
// delete stale email verifications // delete stale email verifications
} }
if (job.name === 'delete_stale_login_requests') { if (job.name === 'delete_stale_login_requests') {

View file

@ -1,10 +1,8 @@
import { LuciaService } from '$lib/server/api/services/lucia.service' import { LuciaService } from '$lib/server/api/services/lucia.service'
import type { MiddlewareHandler } from 'hono' import type { MiddlewareHandler } from 'hono'
import { createMiddleware } from 'hono/factory' import { createMiddleware } from 'hono/factory'
import type { Session, User } from 'lucia'
import { verifyRequestOrigin } from 'oslo/request' import { verifyRequestOrigin } from 'oslo/request'
import { container } from 'tsyringe' import { container } from 'tsyringe'
import { Unauthorized } from '../common/exceptions'
import type { HonoTypes } from '../common/types/hono' import type { HonoTypes } from '../common/types/hono'
// resolve dependencies from the container // resolve dependencies from the container
@ -41,14 +39,3 @@ export const validateAuthSession: MiddlewareHandler<HonoTypes> = createMiddlewar
c.set('user', user) c.set('user', user)
return next() return next()
}) })
export const requireAuth: MiddlewareHandler<{
Variables: {
session: Session
user: User
}
}> = createMiddleware(async (c, next) => {
const user = c.var.user
if (!user) throw Unauthorized('You must be logged in to access this resource')
return next()
})

View file

@ -1,3 +1,5 @@
import { CredentialsType } from '$lib/server/api/databases/tables'
import type { ChangePasswordDto } from '$lib/server/api/dtos/change-password.dto'
import type { UpdateEmailDto } from '$lib/server/api/dtos/update-email.dto' import type { UpdateEmailDto } from '$lib/server/api/dtos/update-email.dto'
import type { UpdateProfileDto } from '$lib/server/api/dtos/update-profile.dto' import type { UpdateProfileDto } from '$lib/server/api/dtos/update-profile.dto'
import type { VerifyPasswordDto } from '$lib/server/api/dtos/verify-password.dto' import type { VerifyPasswordDto } from '$lib/server/api/dtos/verify-password.dto'
@ -68,6 +70,11 @@ export class IamService {
}) })
} }
async updatePassword(userId: string, data: ChangePasswordDto) {
const { password } = data
await this.usersService.updatePassword(userId, password)
}
async verifyPassword(userId: string, data: VerifyPasswordDto) { async verifyPassword(userId: string, data: VerifyPasswordDto) {
const user = await this.usersService.findOneById(userId) const user = await this.usersService.findOneById(userId)
if (!user) { if (!user) {

View file

@ -69,6 +69,22 @@ export class UsersService {
return this.usersRepository.findOneById(id) return this.usersRepository.findOneById(id)
} }
async updatePassword(userId: string, password: string) {
const hashedPassword = await this.tokenService.createHashedToken(password)
const currentCredentials = await this.credentialsRepository.findPasswordCredentialsByUserId(userId)
if (!currentCredentials) {
await this.credentialsRepository.create({
user_id: userId,
type: CredentialsType.PASSWORD,
secret_data: hashedPassword,
})
} else {
await this.credentialsRepository.update(currentCredentials.id, {
secret_data: hashedPassword,
})
}
}
async verifyPassword(userId: string, data: { password: string }) { async verifyPassword(userId: string, data: { password: string }) {
const user = await this.usersRepository.findOneById(userId) const user = await this.usersRepository.findOneById(userId)
if (!user) { if (!user) {

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import LeftNav from '$components/LeftNav.svelte' import { page } from '$app/stores'
import type { Route } from '$lib/types' import type { Route } from '$lib/types'
const routes: Route[] = [ const routes: Route[] = [
@ -10,6 +10,74 @@ const routes: Route[] = [
let { children } = $props() let { children } = $props()
</script> </script>
<LeftNav {routes}> <div class="security-nav">
{@render children()} <nav>
</LeftNav> <div class="mx-auto w-full max-w-6xl mb-2">
<h1 class="text-3xl font-semibold">Settings</h1>
</div>
<ul>
{#each routes as { href, label }}
<li>
<a href={href} class:active={$page.url.pathname.includes(href)}>
{label}
</a>
</li>
{/each}
</ul>
</nav>
<div class="security-nav-content">
{@render children()}
</div>
</div>
<style lang="postcss">
.security-nav {
display: flex;
nav {
width: 16rem;
position: sticky;
top: 0;
left: 0;
background-color: #fff;
padding: 1rem;
border-right: 1px solid #ddd;
height: 100vh;
ul {
list-style-type: none;
padding: 0;
margin: 0;
}
li {
margin-bottom: 0.5rem;
}
a {
text-decoration: none;
color: #337ab7;
display: block;
padding: 0.5rem;
border-radius: 0.25rem;
&:hover {
background-color: #f8f9fa;
}
&.active {
color: var(--color-link-hover);
font-weight: 600;
background-color: #e9ecef;
}
}
}
.security-nav-content {
flex: 1;
padding: 1rem;
margin: 0 auto;
max-width: 80vw;
}
}
</style>

View file

@ -48,33 +48,11 @@ export const actions: Actions = {
}) })
} }
console.log('updating profile') const currentPasswordVerified = await locals.api.me.verify.password
if (!event.locals.user) { .$post({
redirect(302, '/login', notSignedInMessage, event) json: { password: form.data.current_password },
} })
.then(locals.parseApiResponse)
if (!event.locals.session) {
return fail(401)
}
const dbUser = await db.query.usersTable.findFirst({
where: eq(usersTable.id, authedUser.id),
})
// if (!dbUser?.hashed_password) {
// form.data.password = '';
// form.data.confirm_password = '';
// form.data.current_password = '';
// return setError(
// form,
// 'Error occurred. Please try again or contact support if you need further help.',
// );
// }
const currentPasswordVerified = await new Argon2id().verify(
// dbUser.hashed_password,
form.data.current_password,
)
if (!currentPasswordVerified) { if (!currentPasswordVerified) {
return setError(form, 'current_password', 'Your password is incorrect') return setError(form, 'current_password', 'Your password is incorrect')
@ -85,16 +63,9 @@ export const actions: Actions = {
if (form.data.password !== form.data.confirm_password) { if (form.data.password !== form.data.confirm_password) {
return setError(form, 'Password and confirm password do not match') return setError(form, 'Password and confirm password do not match')
} }
const hashedPassword = await new Argon2id().hash(form.data.password) await locals.api.me.change.password.$put({
await lucia.invalidateUserSessions(authedUser.id) json: { password: form.data.password, confirm_password: form.data.confirm_password },
// await db }).then(locals.parseApiResponse)
// .update(usersTable)
// .set({ hashed_password: hashedPassword })
// .where(eq(usersTable.id, user.id));
await lucia.createSession(user.id, {
country: event.locals.session?.ipCountry ?? 'unknown',
})
sessionCookie = lucia.createBlankSessionCookie()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
form.data.password = '' form.data.password = ''

View file

@ -2,7 +2,8 @@
import * as Alert from '$components/ui/alert' import * as Alert from '$components/ui/alert'
import * as Form from '$components/ui/form' import * as Form from '$components/ui/form'
import { Input } from '$components/ui/input' import { Input } from '$components/ui/input'
import { AlertTriangle } from 'lucide-svelte' import { Toggle } from '$components/ui/toggle'
import { AlertTriangle, EyeIcon, EyeOff } from 'lucide-svelte'
import { zodClient } from 'sveltekit-superforms/adapters' import { zodClient } from 'sveltekit-superforms/adapters'
import { superForm } from 'sveltekit-superforms/client' import { superForm } from 'sveltekit-superforms/client'
import { changeUserPasswordSchema } from './schemas' import { changeUserPasswordSchema } from './schemas'
@ -16,13 +17,17 @@ const form = superForm(data.form, {
multipleSubmits: 'prevent', multipleSubmits: 'prevent',
}) })
let hiddenCurrentPassword = $state(true)
let hiddenPassword = $state(true)
let hiddenConfirmPassword = $state(true)
const { form: formData, enhance } = form const { form: formData, enhance } = form
</script> </script>
<form method="POST" use:enhance> <form method="POST" use:enhance>
<h3>Change Password</h3> <h3>Change Password</h3>
<hr class="!border-t-2 mt-2 mb-6" /> <hr class="!border-t-2 mt-2 mb-6" />
<Alert.Root variant="destructive"> <Alert.Root variant="destructive" class="mb-4">
<AlertTriangle class="h-4 w-4" /> <AlertTriangle class="h-4 w-4" />
<Alert.Title>Heads up!</Alert.Title> <Alert.Title>Heads up!</Alert.Title>
<Alert.Description> <Alert.Description>
@ -32,21 +37,30 @@ const { form: formData, enhance } = form
<Form.Field {form} name="current_password"> <Form.Field {form} name="current_password">
<Form.Control let:attrs> <Form.Control let:attrs>
<Form.Label for="current_password">Current Password</Form.Label> <Form.Label for="current_password">Current Password</Form.Label>
<Input {...attrs} bind:value={$formData.current_password} /> <span class="flex gap-1">
<Input {...attrs} autocomplete="password" type={hiddenCurrentPassword ? 'password' : 'text'} bind:value={$formData.current_password} />
<Toggle aria-label={`${hiddenCurrentPassword ? 'Show' : 'Hide' } Current Password}`} onPressedChange={() => hiddenCurrentPassword = !hiddenCurrentPassword}>{#if hiddenCurrentPassword}<EyeIcon />{:else}<EyeOff />{/if}</Toggle>
</span>
</Form.Control> </Form.Control>
<Form.FieldErrors /> <Form.FieldErrors />
</Form.Field> </Form.Field>
<Form.Field {form} name="password"> <Form.Field {form} name="password">
<Form.Control let:attrs> <Form.Control let:attrs>
<Form.Label for="password">New Password</Form.Label> <Form.Label for="password">New Password</Form.Label>
<Input {...attrs} bind:value={$formData.password} /> <span class="flex gap-1">
<Input {...attrs} autocomplete="new-password" type={hiddenPassword ? 'password' : 'text'} bind:value={$formData.password} />
<Toggle aria-label={`${hiddenPassword ? 'Show' : 'Hide' } Password}`} onPressedChange={() => hiddenPassword = !hiddenPassword}>{#if hiddenPassword}<EyeIcon />{:else}<EyeOff />{/if}</Toggle>
</span>
</Form.Control> </Form.Control>
<Form.FieldErrors /> <Form.FieldErrors />
</Form.Field> </Form.Field>
<Form.Field {form} name="confirm_password"> <Form.Field {form} name="confirm_password">
<Form.Control let:attrs> <Form.Control let:attrs>
<Form.Label for="confirm_password">Confirm New Password</Form.Label> <Form.Label for="confirm_password">Confirm New Password</Form.Label>
<Input {...attrs} bind:value={$formData.confirm_password} /> <span class="flex gap-1">
<Input {...attrs} autocomplete="new-password" type={hiddenConfirmPassword ? 'password' : 'text'} bind:value={$formData.confirm_password} />
<Toggle aria-label={`${hiddenConfirmPassword ? 'Show' : 'Hide' } Confirm Password}`} onPressedChange={() => hiddenConfirmPassword = !hiddenConfirmPassword}>{#if hiddenConfirmPassword}<EyeIcon />{:else}<EyeOff />{/if}</Toggle>
</span>
</Form.Control> </Form.Control>
<Form.FieldErrors /> <Form.FieldErrors />
</Form.Field> </Form.Field>

View file

@ -13,8 +13,8 @@ export const changeUserPasswordSchema = z
export type ChangeUserPasswordSchema = typeof changeUserPasswordSchema export type ChangeUserPasswordSchema = typeof changeUserPasswordSchema
const refinePasswords = async (confirm_password: string, password: string, ctx: z.RefinementCtx) => { const refinePasswords = async (confirm_password: string, password: string, ctx: z.RefinementCtx) => {
comparePasswords(confirm_password, password, ctx) await comparePasswords(confirm_password, password, ctx)
checkPasswordStrength(password, ctx) await checkPasswordStrength(password, ctx)
} }
const comparePasswords = async (confirm_password: string, password: string, ctx: z.RefinementCtx) => { const comparePasswords = async (confirm_password: string, password: string, ctx: z.RefinementCtx) => {

View file

@ -1,14 +1,6 @@
import { notSignedInMessage } from '$lib/flashMessages' import { notSignedInMessage } from '$lib/flashMessages'
import env from '$lib/server/api/common/env' import type { Actions } from '@sveltejs/kit'
import { addTwoFactorSchema, removeTwoFactorSchema } from '$lib/validations/account'
import { type Actions, fail } from '@sveltejs/kit'
import kebabCase from 'just-kebab-case'
import { base32, decodeHex } from 'oslo/encoding'
import { createTOTPKeyURI } from 'oslo/otp'
import QRCode from 'qrcode'
import { redirect } from 'sveltekit-flash-message/server' import { redirect } from 'sveltekit-flash-message/server'
import { zod } from 'sveltekit-superforms/adapters'
import { setError, superValidate } from 'sveltekit-superforms/server'
import type { PageServerLoad } from '../../$types' import type { PageServerLoad } from '../../$types'
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
@ -19,7 +11,14 @@ export const load: PageServerLoad = async (event) => {
throw redirect(302, '/login', notSignedInMessage, event) throw redirect(302, '/login', notSignedInMessage, event)
} }
return {} const { data: totpData, error: totpDataError } = await locals.api.mfa.totp.$get().then(locals.parseApiResponse)
const totpEnabled = !!totpData
return {
totpEnabled,
hardwareTokenEnabled: false,
}
} }
export const actions: Actions = {} export const actions: Actions = {}

View file

@ -5,8 +5,8 @@ import * as Card from '$lib/components/ui/card'
const { data } = $props() const { data } = $props()
const totpEnabled = true const totpEnabled = data.totpEnabled
const hardwareTokenEnabled = true const hardwareTokenEnabled = data.hardwareTokenEnabled
</script> </script>
<h1>Two-factor authentication</h1> <h1>Two-factor authentication</h1>
@ -19,7 +19,7 @@ const hardwareTokenEnabled = true
<section> <section>
<div class="two-factor-method"> <div class="two-factor-method">
<div class="two-factor-method-content"> <div class="two-factor-method-content">
<h2>Authenticator app {#if hardwareTokenEnabled}<Badge variant="outline" className="text-green-500 border-green-500">Configured</Badge>{/if}</h2> <h2>Authenticator app {#if totpEnabled}<Badge variant="outline" className="text-green-500 border-green-500">Configured</Badge>{/if}</h2>
<p>Use an authenticator app or browser extension to get two-factor authentication codes when prompted.</p> <p>Use an authenticator app or browser extension to get two-factor authentication codes when prompted.</p>
</div> </div>
<Button href="/settings/security/mfa/totp">Edit</Button> <Button href="/settings/security/mfa/totp">Edit</Button>

View file

@ -1,24 +1,12 @@
import { loadFlash } from 'sveltekit-flash-message/server'; import { loadFlash } from 'sveltekit-flash-message/server'
import type { LayoutServerLoad } from '../$types'; import type { LayoutServerLoad } from '../$types'
// import { userFullyAuthenticated, userNotFullyAuthenticated } from '$lib/server/auth-utils';
// import { lucia } from '$lib/server/auth';
export const load: LayoutServerLoad = loadFlash(async (event) => { export const load: LayoutServerLoad = loadFlash(async (event) => {
const { url, locals, cookies } = event; const { url, locals } = event
const authedUser = await locals.getAuthedUser(); const authedUser = await locals.getAuthedUser()
// if (userNotFullyAuthenticated(user, session)) {
// await lucia.invalidateSession(locals.session!.id!);
// const sessionCookie = lucia.createBlankSessionCookie();
// cookies.set(sessionCookie.name, sessionCookie.value, {
// path: '.',
// ...sessionCookie.attributes,
// });
// }
return { return {
url: url.pathname, url: url.pathname,
// user: userFullyAuthenticated(user, session) ? locals.user : null, authedUser,
user: authedUser, }
}; })
});

View file

@ -1,37 +1,39 @@
<script lang="ts"> <script lang="ts">
import 'iconify-icon'; import 'iconify-icon'
import Header from '$components/Header.svelte'; import Footer from '$components/Footer.svelte'
import Footer from '$components/Footer.svelte'; import Header from '$components/Header.svelte'
const { data, children } = $props(); const { data, children } = $props()
console.log('layout data user', data.user);
</script> </script>
<Header user={data.user} /> <div class="flex min-h-screen w-full flex-col">
<Header user={data.authedUser} />
<main> <main
{@render children()} class="flex min-h-[calc(100vh_-_theme(spacing.16))] flex-1 flex-col gap-4 p-4 md:gap-8 md:p-10"
</main> >
{@render children()}
</main>
<Footer /> <Footer />
</div>
<style lang="postcss"> <style lang="postcss">
main { /*main {*/
flex: 1; /* flex: 1;*/
display: flex; /* display: flex;*/
flex-direction: column; /* flex-direction: column;*/
max-width: 850px; /* max-width: 850px;*/
margin: 0 auto; /* margin: 0 auto;*/
padding: 2rem 0rem; /* padding: 2rem 0rem;*/
max-width: 80vw; /* max-width: 80vw;*/
@media (min-width: 1600px) { /* @media (min-width: 1600px) {*/
max-width: 70vw; /* max-width: 70vw;*/
} /* }*/
box-sizing: border-box; /* box-sizing: border-box;*/
} /*}*/
:global(.dialog-overlay) { :global(.dialog-overlay) {
position: fixed; position: fixed;

View file

@ -1,10 +1,6 @@
import { db } from '$lib/server/api/packages/drizzle'
import { fail } from '@sveltejs/kit' import { fail } from '@sveltejs/kit'
import { eq } from 'drizzle-orm'
import type { MetaTagsProps } from 'svelte-meta-tags' import type { MetaTagsProps } from 'svelte-meta-tags'
import { collections, usersTable, wishlistsTable } from '../../lib/server/api/databases/tables'
import type { PageServerLoad } from './$types' import type { PageServerLoad } from './$types'
// import { userFullyAuthenticated } from '$lib/server/auth-utils';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const { locals, url } = event const { locals, url } = event
@ -61,5 +57,7 @@ export const load: PageServerLoad = async (event) => {
} }
} }
console.log('Not Authed')
return { metaTagsChild: metaTags, user: null, wishlists: [], collections: [] } return { metaTagsChild: metaTags, user: null, wishlists: [], collections: [] }
} }

View file

@ -73,21 +73,6 @@ onNavigate(async (navigation) => {
{/if} {/if}
<MetaTags {...metaTags} /> <MetaTags {...metaTags} />
<PageLoadingIndicator /> <PageLoadingIndicator />
{@render children()}
<div class="layout"> <Toaster />
{@render children()}
</div>
<Toaster />
<!-- <Loading /> -->
<style lang="postcss">
.layout {
display: flex;
position: relative;
flex-direction: column;
min-height: 100vh;
}
</style>