mirror of
https://github.com/BradNut/boredgame
synced 2025-09-08 17:40:22 +00:00
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:
parent
2eee00a20d
commit
3b33880166
31 changed files with 350 additions and 357 deletions
|
|
@ -76,7 +76,7 @@
|
|||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fontsource/fira-mono": "^5.0.14",
|
||||
"@fontsource/fira-mono": "^5.0.15",
|
||||
"@hono/swagger-ui": "^0.4.1",
|
||||
"@hono/zod-openapi": "^0.15.3",
|
||||
"@hono/zod-validator": "^0.2.2",
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ importers:
|
|||
.:
|
||||
dependencies:
|
||||
'@fontsource/fira-mono':
|
||||
specifier: ^5.0.14
|
||||
version: 5.0.14
|
||||
specifier: ^5.0.15
|
||||
version: 5.0.15
|
||||
'@hono/swagger-ui':
|
||||
specifier: ^0.4.1
|
||||
version: 0.4.1(hono@4.5.11)
|
||||
|
|
@ -1245,8 +1245,8 @@ packages:
|
|||
'@floating-ui/utils@0.2.7':
|
||||
resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==}
|
||||
|
||||
'@fontsource/fira-mono@5.0.14':
|
||||
resolution: {integrity: sha512-4IKa+cuHipk/vr2frgZh4pyR2XcoQk/j3zmMlo8uuAGUB3IPLpQlgN6Qm5d3RfRZ7dXGlTn/PWiAJeU8bkmD4w==}
|
||||
'@fontsource/fira-mono@5.0.15':
|
||||
resolution: {integrity: sha512-wc3TpF2GBbtFDKNbb444BrC3mEKuoPLITSYCKweNIrqBvAalIfJGloY/MVrmSGaMNgaAKUpdgy4eAWPLkUVzaA==}
|
||||
|
||||
'@gcornut/valibot-json-schema@0.31.0':
|
||||
resolution: {integrity: sha512-3xGptCurm23e7nuPQkdrE5rEs1FeTPHhAUsBuwwqG4/YeZLwJOoYZv+fmsppUEfo5y9lzUwNQrNqLS/q7HMc7g==}
|
||||
|
|
@ -5489,7 +5489,7 @@ snapshots:
|
|||
|
||||
'@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':
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -4,15 +4,10 @@ import { invalidateAll } from '$app/navigation'
|
|||
import Logo from '$components/logo.svelte'
|
||||
import * as Avatar from '$components/ui/avatar'
|
||||
import * as DropdownMenu from '$components/ui/dropdown-menu'
|
||||
import type { Users } from '$db/schema'
|
||||
import { ListChecks, ListTodo, LogOut, User } from 'lucide-svelte'
|
||||
import { ListChecks, ListTodo, LogOut, Settings } from 'lucide-svelte'
|
||||
import toast from 'svelte-french-toast'
|
||||
|
||||
type HeaderProps = {
|
||||
user: Users | null
|
||||
}
|
||||
|
||||
let { user = null }: HeaderProps = $props()
|
||||
let { user = null } = $props()
|
||||
|
||||
let avatar: string = $derived(user?.username?.slice(0, 1).toUpperCase() || ':)')
|
||||
</script>
|
||||
|
|
@ -28,39 +23,48 @@ let avatar: string = $derived(user?.username?.slice(0, 1).toUpperCase() || ':)')
|
|||
</div>
|
||||
<nav>
|
||||
{#if user}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Avatar.Root asChild>
|
||||
<Avatar.Fallback class="text-3xl font-medium text-magnum-700 h-16 w-16 bg-neutral-100">
|
||||
{avatar}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Label>Account</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<a href="/settings">
|
||||
<DropdownMenu.Item>
|
||||
<User class="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenu.Item>
|
||||
</a>
|
||||
<a href="/collections">
|
||||
<DropdownMenu.Item>
|
||||
<ListChecks class="mr-2 h-4 w-4" />
|
||||
<span>Collections</span>
|
||||
</DropdownMenu.Item>
|
||||
</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={() => {
|
||||
{@render userDropdown()}
|
||||
{:else}
|
||||
<a href="/login"> <span class="flex-auto">Login</span></a>
|
||||
<a href="/signup"> <span class="flex-auto">Sign Up</span></a>
|
||||
{/if}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{#snippet userDropdown()}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Avatar.Root asChild>
|
||||
<Avatar.Fallback class="text-3xl font-medium text-magnum-700 h-16 w-16 bg-neutral-100">
|
||||
{avatar}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Label>Account</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<a href="/settings">
|
||||
<DropdownMenu.Item>
|
||||
<Settings class="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenu.Item>
|
||||
</a>
|
||||
<a href="/collections">
|
||||
<DropdownMenu.Item>
|
||||
<ListChecks class="mr-2 h-4 w-4" />
|
||||
<span>Collections</span>
|
||||
</DropdownMenu.Item>
|
||||
</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 }) => {
|
||||
console.log(result);
|
||||
if (result.type === 'success' || result.type === 'redirect') {
|
||||
|
|
@ -76,26 +80,21 @@ let avatar: string = $derived(user?.username?.slice(0, 1).toUpperCase() || ':)')
|
|||
await applyAction(result);
|
||||
};
|
||||
}}
|
||||
action="/logout"
|
||||
method="POST"
|
||||
>
|
||||
<button type="submit">
|
||||
<div class="flex items-center gap-1">
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
<span>Sign out</span>
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
<a href="/login"> <span class="flex-auto">Login</span></a>
|
||||
<a href="/signup"> <span class="flex-auto">Sign Up</span></a>
|
||||
{/if}
|
||||
</nav>
|
||||
</header>
|
||||
action="/logout"
|
||||
method="POST"
|
||||
>
|
||||
<button type="submit">
|
||||
<div class="flex items-center gap-1">
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
<span>Sign out</span>
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/snippet}
|
||||
|
||||
<style lang="postcss">
|
||||
header {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
import type { InputEvents } from "./index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { cn } from "$lib/utils/ui.js";
|
||||
|
||||
type $$Props = HTMLInputAttributes;
|
||||
type $$Events = InputEvents;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { type VariantProps, tv } from "tailwind-variants";
|
|||
import Root from "./toggle.svelte";
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
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: {
|
||||
default: "h-10 px-3",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { Toggle as TogglePrimitive } from "bits-ui";
|
||||
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 & {
|
||||
variant?: Variant;
|
||||
|
|
|
|||
|
|
@ -1,36 +1,37 @@
|
|||
<script lang="ts">
|
||||
import { navigating } from '$app/stores';
|
||||
import { onNavigate } from '$app/navigation';
|
||||
let visible = false;
|
||||
let progress = 0;
|
||||
let load_durations: number[] = [];
|
||||
$: average_load = load_durations.reduce((a, b) => a + b, 0) / load_durations.length;
|
||||
const increment = 1;
|
||||
onNavigate((navigation) => {
|
||||
const typical_load_time = average_load || 200; //ms
|
||||
const frequency = typical_load_time / 100;
|
||||
let start = performance.now();
|
||||
// Start the progress bar
|
||||
visible = true;
|
||||
progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
// Increment the progress bar
|
||||
progress += increment;
|
||||
}, frequency);
|
||||
// Resolve the promise when the page is done loading
|
||||
$navigating?.complete.then(() => {
|
||||
progress = 100; // Fill out the progress bar
|
||||
clearInterval(interval);
|
||||
// after 100 ms hide the progress bar
|
||||
setTimeout(() => {
|
||||
visible = false;
|
||||
}, 500);
|
||||
// Log how long that one took
|
||||
const end = performance.now();
|
||||
const duration = end - start;
|
||||
load_durations = [...load_durations, duration];
|
||||
});
|
||||
});
|
||||
import { onNavigate } from '$app/navigation'
|
||||
import { navigating } from '$app/stores'
|
||||
|
||||
let visible = false
|
||||
let progress = 0
|
||||
let load_durations: number[] = []
|
||||
$: average_load = load_durations.reduce((a, b) => a + b, 0) / load_durations.length
|
||||
const increment = 1
|
||||
onNavigate((navigation) => {
|
||||
const typical_load_time = average_load || 200 //ms
|
||||
const frequency = typical_load_time / 100
|
||||
let start = performance.now()
|
||||
// Start the progress bar
|
||||
visible = true
|
||||
progress = 0
|
||||
const interval = setInterval(() => {
|
||||
// Increment the progress bar
|
||||
progress += increment
|
||||
}, frequency)
|
||||
// Resolve the promise when the page is done loading
|
||||
$navigating?.complete.then(() => {
|
||||
progress = 100 // Fill out the progress bar
|
||||
clearInterval(interval)
|
||||
// after 100 ms hide the progress bar
|
||||
setTimeout(() => {
|
||||
visible = false
|
||||
}, 500)
|
||||
// Log how long that one took
|
||||
const end = performance.now()
|
||||
const duration = end - start
|
||||
load_durations = [...load_durations, duration]
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="progress" class:visible style:--progress={progress}>
|
||||
|
|
|
|||
|
|
@ -34,5 +34,3 @@ export const config: Config = {
|
|||
max: env.DB_MIGRATING || env.DB_SEEDING ? 1 : undefined,
|
||||
},
|
||||
}
|
||||
|
||||
console.log('config', config)
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
import { StatusCodes } from '$lib/constants/status-codes';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { StatusCodes } from '$lib/constants/status-codes'
|
||||
import { HTTPException } from 'hono/http-exception'
|
||||
|
||||
export function TooManyRequests(message: string = 'Too many requests') {
|
||||
return new HTTPException(StatusCodes.TOO_MANY_REQUESTS, { message });
|
||||
export function TooManyRequests(message = 'Too many requests') {
|
||||
return new HTTPException(StatusCodes.TOO_MANY_REQUESTS, { message })
|
||||
}
|
||||
|
||||
export function Forbidden(message: string = 'Forbidden') {
|
||||
return new HTTPException(StatusCodes.FORBIDDEN, { message });
|
||||
export function Forbidden(message = 'Forbidden') {
|
||||
return new HTTPException(StatusCodes.FORBIDDEN, { message })
|
||||
}
|
||||
|
||||
export function Unauthorized(message: string = 'Unauthorized') {
|
||||
return new HTTPException(StatusCodes.UNAUTHORIZED, { message });
|
||||
export function Unauthorized(message = 'Unauthorized') {
|
||||
return new HTTPException(StatusCodes.UNAUTHORIZED, { message })
|
||||
}
|
||||
|
||||
export function NotFound(message: string = 'Not Found') {
|
||||
return new HTTPException(StatusCodes.NOT_FOUND, { message });
|
||||
export function NotFound(message = 'Not Found') {
|
||||
return new HTTPException(StatusCodes.NOT_FOUND, { message })
|
||||
}
|
||||
|
||||
export function BadRequest(message: string = 'Bad Request') {
|
||||
return new HTTPException(StatusCodes.BAD_REQUEST, { message });
|
||||
export function BadRequest(message = 'Bad Request') {
|
||||
return new HTTPException(StatusCodes.BAD_REQUEST, { message })
|
||||
}
|
||||
|
||||
export function InternalError(message: string = 'Internal Error') {
|
||||
return new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { message });
|
||||
export function InternalError(message = 'Internal Error') {
|
||||
return new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { message })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'reflect-metadata'
|
|||
import { Controller } from '$lib/server/api/common/types/controller'
|
||||
import { CollectionsService } from '$lib/server/api/services/collections.service'
|
||||
import { inject, injectable } from 'tsyringe'
|
||||
import { requireAuth } from '../middleware/auth.middleware'
|
||||
import { requireAuth } from '../middleware/require-auth.middleware'
|
||||
|
||||
@injectable()
|
||||
export class CollectionController extends Controller {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
import { StatusCodes } from '$lib/constants/status-codes'
|
||||
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 { updateProfileDto } from '$lib/server/api/dtos/update-profile.dto'
|
||||
import { verifyPasswordDto } from '$lib/server/api/dtos/verify-password.dto'
|
||||
import { limiter } from '$lib/server/api/middleware/rate-limiter.middleware'
|
||||
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 { zValidator } from '@hono/zod-validator'
|
||||
import { setCookie } from 'hono/cookie'
|
||||
import { inject, injectable } from 'tsyringe'
|
||||
import { requireAuth } from '../middleware/auth.middleware'
|
||||
import { requireAuth } from '../middleware/require-auth.middleware'
|
||||
|
||||
@injectable()
|
||||
export class IamController extends Controller {
|
||||
constructor(
|
||||
@inject(IamService) private readonly iamService: IamService,
|
||||
@inject(LoginRequestsService) private readonly loginRequestService: LoginRequestsService,
|
||||
@inject(LuciaService) private luciaService: LuciaService,
|
||||
) {
|
||||
super()
|
||||
|
|
@ -45,6 +48,32 @@ export class IamController extends Controller {
|
|||
}
|
||||
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) => {
|
||||
const user = c.var.user
|
||||
const { email } = c.req.valid('json')
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { UsersService } from '$lib/server/api/services/users.service'
|
|||
import { zValidator } from '@hono/zod-validator'
|
||||
import { inject, injectable } from 'tsyringe'
|
||||
import { CredentialsType } from '../databases/tables'
|
||||
import { requireAuth } from '../middleware/auth.middleware'
|
||||
import { requireAuth } from '../middleware/require-auth.middleware'
|
||||
|
||||
@injectable()
|
||||
export class MfaController extends Controller {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'reflect-metadata'
|
|||
import { Controller } from '$lib/server/api/common/types/controller'
|
||||
import { UsersService } from '$lib/server/api/services/users.service'
|
||||
import { inject, injectable } from 'tsyringe'
|
||||
import { requireAuth } from '../middleware/auth.middleware'
|
||||
import { requireAuth } from '../middleware/require-auth.middleware'
|
||||
|
||||
@injectable()
|
||||
export class UserController extends Controller {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'reflect-metadata'
|
|||
import { Controller } from '$lib/server/api/common/types/controller'
|
||||
import { WishlistsService } from '$lib/server/api/services/wishlists.service'
|
||||
import { inject, injectable } from 'tsyringe'
|
||||
import { requireAuth } from '../middleware/auth.middleware'
|
||||
import { requireAuth } from '../middleware/require-auth.middleware'
|
||||
|
||||
@injectable()
|
||||
export class WishlistController extends Controller {
|
||||
|
|
|
|||
17
src/lib/server/api/dtos/change-password.dto.ts
Normal file
17
src/lib/server/api/dtos/change-password.dto.ts
Normal 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>
|
||||
|
|
@ -1,23 +1,14 @@
|
|||
import { z } from "zod";
|
||||
import { z } from 'zod'
|
||||
|
||||
export const updateProfileDto = z.object({
|
||||
firstName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, {message: 'Must be at least 3 characters'})
|
||||
.max(50, {message: 'Must be less than 50 characters'})
|
||||
.min(3, { message: 'Must be at least 3 characters' })
|
||||
.max(50, { message: 'Must be less than 50 characters' })
|
||||
.optional(),
|
||||
lastName: z
|
||||
.string()
|
||||
.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'})
|
||||
});
|
||||
lastName: z.string().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>
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ export class AuthCleanupJobs {
|
|||
this.queue = this.jobsService.createQueue('test')
|
||||
|
||||
/* ---------------------------- 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() {
|
||||
await this.queue.add('delete_stale_email_verifiactions', null, {
|
||||
await this.queue.add('delete_stale_email_verifications', null, {
|
||||
repeat: {
|
||||
pattern: '0 0 * * 0', // Runs once a week at midnight on Sunday
|
||||
},
|
||||
|
|
@ -31,7 +31,7 @@ export class AuthCleanupJobs {
|
|||
|
||||
private async worker() {
|
||||
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
|
||||
}
|
||||
if (job.name === 'delete_stale_login_requests') {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { LuciaService } from '$lib/server/api/services/lucia.service'
|
||||
import type { MiddlewareHandler } from 'hono'
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import type { Session, User } from 'lucia'
|
||||
import { verifyRequestOrigin } from 'oslo/request'
|
||||
import { container } from 'tsyringe'
|
||||
import { Unauthorized } from '../common/exceptions'
|
||||
import type { HonoTypes } from '../common/types/hono'
|
||||
|
||||
// resolve dependencies from the container
|
||||
|
|
@ -41,14 +39,3 @@ export const validateAuthSession: MiddlewareHandler<HonoTypes> = createMiddlewar
|
|||
c.set('user', user)
|
||||
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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 { UpdateProfileDto } from '$lib/server/api/dtos/update-profile.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) {
|
||||
const user = await this.usersService.findOneById(userId)
|
||||
if (!user) {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,22 @@ export class UsersService {
|
|||
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 }) {
|
||||
const user = await this.usersRepository.findOneById(userId)
|
||||
if (!user) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import LeftNav from '$components/LeftNav.svelte'
|
||||
import { page } from '$app/stores'
|
||||
import type { Route } from '$lib/types'
|
||||
|
||||
const routes: Route[] = [
|
||||
|
|
@ -10,6 +10,74 @@ const routes: Route[] = [
|
|||
let { children } = $props()
|
||||
</script>
|
||||
|
||||
<LeftNav {routes}>
|
||||
{@render children()}
|
||||
</LeftNav>
|
||||
<div class="security-nav">
|
||||
<nav>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -48,33 +48,11 @@ export const actions: Actions = {
|
|||
})
|
||||
}
|
||||
|
||||
console.log('updating profile')
|
||||
if (!event.locals.user) {
|
||||
redirect(302, '/login', notSignedInMessage, event)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
const currentPasswordVerified = await locals.api.me.verify.password
|
||||
.$post({
|
||||
json: { password: form.data.current_password },
|
||||
})
|
||||
.then(locals.parseApiResponse)
|
||||
|
||||
if (!currentPasswordVerified) {
|
||||
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) {
|
||||
return setError(form, 'Password and confirm password do not match')
|
||||
}
|
||||
const hashedPassword = await new Argon2id().hash(form.data.password)
|
||||
await lucia.invalidateUserSessions(authedUser.id)
|
||||
// await db
|
||||
// .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()
|
||||
await locals.api.me.change.password.$put({
|
||||
json: { password: form.data.password, confirm_password: form.data.confirm_password },
|
||||
}).then(locals.parseApiResponse)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
form.data.password = ''
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
import * as Alert from '$components/ui/alert'
|
||||
import * as Form from '$components/ui/form'
|
||||
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 { superForm } from 'sveltekit-superforms/client'
|
||||
import { changeUserPasswordSchema } from './schemas'
|
||||
|
|
@ -16,13 +17,17 @@ const form = superForm(data.form, {
|
|||
multipleSubmits: 'prevent',
|
||||
})
|
||||
|
||||
let hiddenCurrentPassword = $state(true)
|
||||
let hiddenPassword = $state(true)
|
||||
let hiddenConfirmPassword = $state(true)
|
||||
|
||||
const { form: formData, enhance } = form
|
||||
</script>
|
||||
|
||||
<form method="POST" use:enhance>
|
||||
<h3>Change Password</h3>
|
||||
<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" />
|
||||
<Alert.Title>Heads up!</Alert.Title>
|
||||
<Alert.Description>
|
||||
|
|
@ -32,21 +37,30 @@ const { form: formData, enhance } = form
|
|||
<Form.Field {form} name="current_password">
|
||||
<Form.Control let:attrs>
|
||||
<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.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="password">
|
||||
<Form.Control let:attrs>
|
||||
<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.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="confirm_password">
|
||||
<Form.Control let:attrs>
|
||||
<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.FieldErrors />
|
||||
</Form.Field>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ export const changeUserPasswordSchema = z
|
|||
export type ChangeUserPasswordSchema = typeof changeUserPasswordSchema
|
||||
|
||||
const refinePasswords = async (confirm_password: string, password: string, ctx: z.RefinementCtx) => {
|
||||
comparePasswords(confirm_password, password, ctx)
|
||||
checkPasswordStrength(password, ctx)
|
||||
await comparePasswords(confirm_password, password, ctx)
|
||||
await checkPasswordStrength(password, ctx)
|
||||
}
|
||||
|
||||
const comparePasswords = async (confirm_password: string, password: string, ctx: z.RefinementCtx) => {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,6 @@
|
|||
import { notSignedInMessage } from '$lib/flashMessages'
|
||||
import env from '$lib/server/api/common/env'
|
||||
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 type { Actions } from '@sveltejs/kit'
|
||||
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'
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
|
|
@ -19,7 +11,14 @@ export const load: PageServerLoad = async (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 = {}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import * as Card from '$lib/components/ui/card'
|
|||
|
||||
const { data } = $props()
|
||||
|
||||
const totpEnabled = true
|
||||
const hardwareTokenEnabled = true
|
||||
const totpEnabled = data.totpEnabled
|
||||
const hardwareTokenEnabled = data.hardwareTokenEnabled
|
||||
</script>
|
||||
|
||||
<h1>Two-factor authentication</h1>
|
||||
|
|
@ -19,7 +19,7 @@ const hardwareTokenEnabled = true
|
|||
<section>
|
||||
<div class="two-factor-method">
|
||||
<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>
|
||||
</div>
|
||||
<Button href="/settings/security/mfa/totp">Edit</Button>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,12 @@
|
|||
import { loadFlash } from 'sveltekit-flash-message/server';
|
||||
import type { LayoutServerLoad } from '../$types';
|
||||
// import { userFullyAuthenticated, userNotFullyAuthenticated } from '$lib/server/auth-utils';
|
||||
// import { lucia } from '$lib/server/auth';
|
||||
import { loadFlash } from 'sveltekit-flash-message/server'
|
||||
import type { LayoutServerLoad } from '../$types'
|
||||
|
||||
export const load: LayoutServerLoad = loadFlash(async (event) => {
|
||||
const { url, locals, cookies } = event;
|
||||
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,
|
||||
// });
|
||||
// }
|
||||
const { url, locals } = event
|
||||
const authedUser = await locals.getAuthedUser()
|
||||
|
||||
return {
|
||||
url: url.pathname,
|
||||
// user: userFullyAuthenticated(user, session) ? locals.user : null,
|
||||
user: authedUser,
|
||||
};
|
||||
});
|
||||
authedUser,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,37 +1,39 @@
|
|||
<script lang="ts">
|
||||
import 'iconify-icon';
|
||||
import Header from '$components/Header.svelte';
|
||||
import Footer from '$components/Footer.svelte';
|
||||
import 'iconify-icon'
|
||||
import Footer from '$components/Footer.svelte'
|
||||
import Header from '$components/Header.svelte'
|
||||
|
||||
const { data, children } = $props();
|
||||
|
||||
console.log('layout data user', data.user);
|
||||
const { data, children } = $props()
|
||||
</script>
|
||||
|
||||
<Header user={data.user} />
|
||||
<div class="flex min-h-screen w-full flex-col">
|
||||
<Header user={data.authedUser} />
|
||||
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
<main
|
||||
class="flex min-h-[calc(100vh_-_theme(spacing.16))] flex-1 flex-col gap-4 p-4 md:gap-8 md:p-10"
|
||||
>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 850px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0rem;
|
||||
max-width: 80vw;
|
||||
/*main {*/
|
||||
/* flex: 1;*/
|
||||
/* display: flex;*/
|
||||
/* flex-direction: column;*/
|
||||
/* max-width: 850px;*/
|
||||
/* margin: 0 auto;*/
|
||||
/* padding: 2rem 0rem;*/
|
||||
/* max-width: 80vw;*/
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
max-width: 70vw;
|
||||
}
|
||||
/* @media (min-width: 1600px) {*/
|
||||
/* max-width: 70vw;*/
|
||||
/* }*/
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* box-sizing: border-box;*/
|
||||
/*}*/
|
||||
|
||||
:global(.dialog-overlay) {
|
||||
position: fixed;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import { db } from '$lib/server/api/packages/drizzle'
|
||||
import { fail } from '@sveltejs/kit'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { MetaTagsProps } from 'svelte-meta-tags'
|
||||
import { collections, usersTable, wishlistsTable } from '../../lib/server/api/databases/tables'
|
||||
import type { PageServerLoad } from './$types'
|
||||
// import { userFullyAuthenticated } from '$lib/server/auth-utils';
|
||||
|
||||
export const load: PageServerLoad = async (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: [] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,21 +73,6 @@ onNavigate(async (navigation) => {
|
|||
{/if}
|
||||
|
||||
<MetaTags {...metaTags} />
|
||||
|
||||
<PageLoadingIndicator />
|
||||
|
||||
<div class="layout">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<!-- <Loading /> -->
|
||||
|
||||
<style lang="postcss">
|
||||
.layout {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
{@render children()}
|
||||
<Toaster />
|
||||
Loading…
Reference in a new issue