Merge pull request #26 from BradNut/hono

Hono
This commit is contained in:
Bradley Shellnut 2024-09-22 21:21:32 +00:00 committed by GitHub
commit a6df74501b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 4004 additions and 1317 deletions

View file

@ -18,6 +18,12 @@ ADMIN_PASSWORD=
TWO_FACTOR_TIMEOUT=300000 TWO_FACTOR_TIMEOUT=300000
# OAuth
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
# Public # Public
PUBLIC_SITE_NAME='Bored Game' PUBLIC_SITE_NAME='Bored Game'

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License Copyright (c) 2024 Bradley Shellnut
Permission is hereby granted,
free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice
(including the next paragraph) shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,5 +1,5 @@
import 'dotenv/config' import 'dotenv/config'
import env from '$lib/server/api/common/env' import env from './src/lib/server/api/common/env'
import { defineConfig } from 'drizzle-kit' import { defineConfig } from 'drizzle-kit'
export default defineConfig({ export default defineConfig({

View file

@ -27,19 +27,20 @@
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@melt-ui/pp": "^0.3.2", "@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.83.0", "@melt-ui/svelte": "^0.83.0",
"@playwright/test": "^1.47.0", "@playwright/test": "^1.47.2",
"@sveltejs/adapter-auto": "^3.2.4", "@sveltejs/adapter-auto": "^3.2.5",
"@sveltejs/enhanced-img": "^0.3.4", "@sveltejs/enhanced-img": "^0.3.8",
"@sveltejs/kit": "^2.5.26", "@sveltejs/kit": "^2.5.28",
"@sveltejs/vite-plugin-svelte": "^3.1.2", "@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/cookie": "^0.6.0", "@types/cookie": "^0.6.0",
"@types/node": "^20.16.5", "@types/node": "^20.16.5",
"@types/pg": "^8.11.8", "@types/pg": "^8.11.10",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
"arctic": "^1.9.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"drizzle-kit": "^0.23.2", "drizzle-kit": "^0.23.2",
"eslint": "^8.57.0", "eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "2.36.0-next.13", "eslint-plugin-svelte": "2.36.0-next.13",
"just-clone": "^6.2.0", "just-clone": "^6.2.0",
@ -47,13 +48,12 @@
"lucia": "3.2.0", "lucia": "3.2.0",
"lucide-svelte": "^0.408.0", "lucide-svelte": "^0.408.0",
"nodemailer": "^6.9.15", "nodemailer": "^6.9.15",
"postcss": "^8.4.45", "postcss": "^8.4.47",
"postcss-import": "^16.1.0", "postcss-import": "^16.1.0",
"postcss-load-config": "^5.1.0", "postcss-load-config": "^5.1.0",
"postcss-preset-env": "^9.6.0", "postcss-preset-env": "^9.6.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.2.6",
"sass": "^1.78.0",
"satori": "^0.10.14", "satori": "^0.10.14",
"satori-html": "^0.3.2", "satori-html": "^0.3.2",
"svelte": "5.0.0-next.175", "svelte": "5.0.0-next.175",
@ -64,19 +64,19 @@
"svelte-sequential-preprocessor": "^2.0.1", "svelte-sequential-preprocessor": "^2.0.1",
"sveltekit-flash-message": "^2.4.4", "sveltekit-flash-message": "^2.4.4",
"sveltekit-rate-limiter": "^0.5.2", "sveltekit-rate-limiter": "^0.5.2",
"sveltekit-superforms": "^2.17.0", "sveltekit-superforms": "^2.19.0",
"tailwindcss": "^3.4.10", "tailwindcss": "^3.4.12",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tslib": "^2.7.0", "tslib": "^2.7.0",
"tsx": "^4.19.0", "tsx": "^4.19.1",
"typescript": "^5.5.4", "typescript": "^5.6.2",
"vite": "^5.4.3", "vite": "^5.4.7",
"vitest": "^1.6.0", "vitest": "^1.6.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@fontsource/fira-mono": "^5.0.14", "@fontsource/fira-mono": "^5.1.0",
"@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",
@ -88,14 +88,13 @@
"@neondatabase/serverless": "^0.9.5", "@neondatabase/serverless": "^0.9.5",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@sveltejs/adapter-node": "^5.2.2", "@sveltejs/adapter-node": "^5.2.4",
"@sveltejs/adapter-vercel": "^5.4.3", "@sveltejs/adapter-vercel": "^5.4.4",
"@types/feather-icons": "^4.29.4", "@types/feather-icons": "^4.29.4",
"@vercel/og": "^0.5.20", "@vercel/og": "^0.5.20",
"arctic": "^1.9.2", "bits-ui": "^0.21.15",
"bits-ui": "^0.21.13",
"boardgamegeekclient": "^1.9.1", "boardgamegeekclient": "^1.9.1",
"bullmq": "^5.12.14", "bullmq": "^5.13.2",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cookie": "^0.6.0", "cookie": "^0.6.0",
@ -106,7 +105,7 @@
"feather-icons": "^4.29.2", "feather-icons": "^4.29.2",
"formsnap": "^1.0.1", "formsnap": "^1.0.1",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"hono": "^4.5.11", "hono": "^4.6.2",
"hono-rate-limiter": "^0.4.0", "hono-rate-limiter": "^0.4.0",
"html-entities": "^2.5.2", "html-entities": "^2.5.2",
"iconify-icon": "^2.1.0", "iconify-icon": "^2.1.0",
@ -116,7 +115,7 @@
"loader": "^2.1.1", "loader": "^2.1.1",
"open-props": "^1.7.6", "open-props": "^1.7.6",
"oslo": "^1.2.1", "oslo": "^1.2.1",
"pg": "^8.12.0", "pg": "^8.13.0",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"radix-svelte": "^0.9.0", "radix-svelte": "^0.9.0",
@ -128,6 +127,7 @@
"tailwind-variants": "^0.2.1", "tailwind-variants": "^0.2.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsyringe": "^4.8.0", "tsyringe": "^4.8.0",
"zod-to-json-schema": "^3.23.2" "zod-to-json-schema": "^3.23.3"
} },
} "license": "MIT"
}

File diff suppressed because it is too large Load diff

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

@ -17,6 +17,10 @@ const EnvSchema = z.object({
DATABASE_DB: z.string(), DATABASE_DB: z.string(),
DB_MIGRATING: stringBoolean, DB_MIGRATING: stringBoolean,
DB_SEEDING: stringBoolean, DB_SEEDING: stringBoolean,
GITHUB_CLIENT_ID: z.string(),
GITHUB_CLIENT_SECRET: z.string(),
GOOGLE_CLIENT_ID: z.string(),
GOOGLE_CLIENT_SECRET: z.string(),
NODE_ENV: z.string().default('development'), NODE_ENV: z.string().default('development'),
ORIGIN: z.string(), ORIGIN: z.string(),
PUBLIC_SITE_NAME: z.string(), PUBLIC_SITE_NAME: z.string(),

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

@ -0,0 +1,11 @@
export type OAuthUser = {
sub: string;
given_name?: string;
family_name?: string;
picture?: string;
username: string;
email?: string;
email_verified?: boolean;
}
export type OAuthProviders = 'github' | 'google' | 'apple'

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 {
@ -18,6 +18,11 @@ export class CollectionController extends Controller {
console.log('collections service', collections) console.log('collections service', collections)
return c.json({ collections }) return c.json({ collections })
}) })
.get('/count', requireAuth, async (c) => {
const user = c.var.user
const collections = await this.collectionsService.findAllByUserIdWithDetails(user.id)
return c.json({ collections })
})
.get('/:cuid', requireAuth, async (c) => { .get('/:cuid', requireAuth, async (c) => {
const cuid = c.req.param('cuid') const cuid = c.req.param('cuid')
const collection = await this.collectionsService.findOneByCuid(cuid) const collection = await this.collectionsService.findOneByCuid(cuid)

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

@ -0,0 +1,150 @@
import 'reflect-metadata'
import { Controller } from '$lib/server/api/common/types/controller'
import { LuciaService } from '$lib/server/api/services/lucia.service'
import { OAuthService } from '$lib/server/api/services/oauth.service'
import { github, google } from '$lib/server/auth'
import { OAuth2RequestError } from 'arctic'
import { getCookie, setCookie } from 'hono/cookie'
import { TimeSpan } from 'oslo'
import { inject, injectable } from 'tsyringe'
import type {OAuthUser} from "$lib/server/api/common/types/oauth";
@injectable()
export class OAuthController extends Controller {
constructor(
@inject(LuciaService) private luciaService: LuciaService,
@inject(OAuthService) private oauthService: OAuthService,
) {
super()
}
routes() {
return this.controller
.get('/github', async (c) => {
try {
const code = c.req.query('code')?.toString() ?? null
const state = c.req.query('state')?.toString() ?? null
const storedState = getCookie(c).github_oauth_state ?? null
if (!code || !state || !storedState || state !== storedState) {
return c.body(null, 400)
}
const tokens = await github.validateAuthorizationCode(code)
const githubUserResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
})
const githubUser: GitHubUser = await githubUserResponse.json()
const oAuthUser: OAuthUser = {
sub: `${githubUser.id}`,
username: githubUser.login,
email: undefined
}
const userId = await this.oauthService.handleOAuthUser(oAuthUser, 'github')
const session = await this.luciaService.lucia.createSession(userId, {})
const sessionCookie = this.luciaService.lucia.createSessionCookie(session.id)
setCookie(c, sessionCookie.name, sessionCookie.value, {
path: sessionCookie.attributes.path,
maxAge:
sessionCookie?.attributes?.maxAge && sessionCookie?.attributes?.maxAge < new TimeSpan(365, 'd').seconds()
? sessionCookie.attributes.maxAge
: new TimeSpan(2, 'w').seconds(),
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({ message: 'ok' })
} catch (error) {
console.error(error)
// the specific error message depends on the provider
if (error instanceof OAuth2RequestError) {
// invalid code
return c.body(null, 400)
}
return c.body(null, 500)
}
})
.get('/google', async (c) => {
try {
const code = c.req.query('code')?.toString() ?? null
const state = c.req.query('state')?.toString() ?? null
const storedState = getCookie(c).google_oauth_state ?? null
const storedCodeVerifier = getCookie(c).google_oauth_code_verifier ?? null
if (!code || !storedState || !storedCodeVerifier || state !== storedState) {
return c.body(null, 400)
}
const tokens = await google.validateAuthorizationCode(code, storedCodeVerifier)
const googleUserResponse = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
})
const googleUser: GoogleUser = await googleUserResponse.json()
const oAuthUser: OAuthUser = {
sub: googleUser.sub,
given_name: googleUser.given_name,
family_name: googleUser.family_name,
picture: googleUser.picture,
username: googleUser.email,
email: googleUser.email,
email_verified: googleUser.email_verified,
}
const userId = await this.oauthService.handleOAuthUser(oAuthUser, 'google')
const session = await this.luciaService.lucia.createSession(userId, {})
const sessionCookie = this.luciaService.lucia.createSessionCookie(session.id)
setCookie(c, sessionCookie.name, sessionCookie.value, {
path: sessionCookie.attributes.path,
maxAge:
sessionCookie?.attributes?.maxAge && sessionCookie?.attributes?.maxAge < new TimeSpan(365, 'd').seconds()
? sessionCookie.attributes.maxAge
: new TimeSpan(2, 'w').seconds(),
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({ message: 'ok' })
} catch (error) {
console.error(error)
// the specific error message depends on the provider
if (error instanceof OAuth2RequestError) {
// invalid code
return c.body(null, 400)
}
return c.body(null, 500)
}
})
}
}
interface GitHubUser {
id: number
login: string
}
interface GoogleUser {
sub: string
name: string
given_name: string
family_name: string
picture: string
email: string
email_verified: boolean
}

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,2 @@
ALTER TABLE "users" ADD COLUMN "email_verified" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "picture" text;

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,13 @@
"when": 1725489682980, "when": 1725489682980,
"tag": "0000_volatile_warhawk", "tag": "0000_volatile_warhawk",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1726877846811,
"tag": "0001_pink_the_enforcers",
"breakpoints": true
} }
] ]
} }

View file

@ -1,5 +1,5 @@
import * as schema from '$lib/server/api/databases/tables' import * as schema from '$lib/server/api/databases/tables'
import { type db } from '$lib/server/api/packages/drizzle' import type { db } from '$lib/server/api/packages/drizzle'
import roles from './data/roles.json' import roles from './data/roles.json'
export default async function seed(db: db) { export default async function seed(db: db) {

View file

@ -3,6 +3,7 @@ import { type InferSelectModel, relations } from 'drizzle-orm'
import { pgTable, text, uuid } from 'drizzle-orm/pg-core' import { pgTable, text, uuid } from 'drizzle-orm/pg-core'
import { timestamps } from '../../common/utils/table' import { timestamps } from '../../common/utils/table'
import { usersTable } from './users.table' import { usersTable } from './users.table'
import { collection_items } from './collectionItems.table'
export const collections = pgTable('collections', { export const collections = pgTable('collections', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
@ -16,11 +17,12 @@ export const collections = pgTable('collections', {
...timestamps, ...timestamps,
}) })
export const collection_relations = relations(collections, ({ one }) => ({ export const collection_relations = relations(collections, ({ one, many }) => ({
user: one(usersTable, { user: one(usersTable, {
fields: [collections.user_id], fields: [collections.user_id],
references: [usersTable.id], references: [usersTable.id],
}), }),
collection_items: many(collection_items),
})) }))
export type Collections = InferSelectModel<typeof collections> export type Collections = InferSelectModel<typeof collections>

View file

@ -4,6 +4,13 @@ import { pgTable, text, uuid } from 'drizzle-orm/pg-core'
import { timestamps } from '../../common/utils/table' import { timestamps } from '../../common/utils/table'
import { user_roles } from './userRoles.table' import { user_roles } from './userRoles.table'
export enum RoleName {
ADMIN = 'admin',
EDITOR = 'editor',
MODERATOR = 'moderator',
USER = 'user',
}
export const rolesTable = pgTable('roles', { export const rolesTable = pgTable('roles', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
cuid: text('cuid') cuid: text('cuid')

View file

@ -15,6 +15,8 @@ export const usersTable = pgTable('users', {
last_name: text('last_name'), last_name: text('last_name'),
verified: boolean('verified').default(false), verified: boolean('verified').default(false),
receive_email: boolean('receive_email').default(false), receive_email: boolean('receive_email').default(false),
email_verified: boolean('email_verified').default(false),
picture: text('picture'),
mfa_enabled: boolean('mfa_enabled').notNull().default(false), mfa_enabled: boolean('mfa_enabled').notNull().default(false),
theme: text('theme').default('system'), theme: text('theme').default('system'),
...timestamps, ...timestamps,

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

@ -1,6 +1,7 @@
import 'reflect-metadata' import 'reflect-metadata'
import { CollectionController } from '$lib/server/api/controllers/collection.controller' import { CollectionController } from '$lib/server/api/controllers/collection.controller'
import { MfaController } from '$lib/server/api/controllers/mfa.controller' import { MfaController } from '$lib/server/api/controllers/mfa.controller'
import { OAuthController } from '$lib/server/api/controllers/oauth.controller'
import { SignupController } from '$lib/server/api/controllers/signup.controller' import { SignupController } from '$lib/server/api/controllers/signup.controller'
import { UserController } from '$lib/server/api/controllers/user.controller' import { UserController } from '$lib/server/api/controllers/user.controller'
import { WishlistController } from '$lib/server/api/controllers/wishlist.controller' import { WishlistController } from '$lib/server/api/controllers/wishlist.controller'
@ -44,6 +45,7 @@ const routes = app
.route('/me', container.resolve(IamController).routes()) .route('/me', container.resolve(IamController).routes())
.route('/user', container.resolve(UserController).routes()) .route('/user', container.resolve(UserController).routes())
.route('/login', container.resolve(LoginController).routes()) .route('/login', container.resolve(LoginController).routes())
.route('/oauth', container.resolve(OAuthController).routes())
.route('/signup', container.resolve(SignupController).routes()) .route('/signup', container.resolve(SignupController).routes())
.route('/wishlists', container.resolve(WishlistController).routes()) .route('/wishlists', container.resolve(WishlistController).routes())
.route('/collections', container.resolve(CollectionController).routes()) .route('/collections', container.resolve(CollectionController).routes())

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

@ -51,6 +51,23 @@ export class CollectionsRepository {
}) })
} }
async findAllByUserIdWithDetails(userId: string, db = this.drizzle.db) {
return db.query.collections.findMany({
where: eq(collections.user_id, userId),
columns: {
cuid: true,
name: true,
},
with: {
collection_items: {
columns: {
cuid: true,
},
},
},
})
}
async create(data: CreateCollection, db = this.drizzle.db) { async create(data: CreateCollection, db = this.drizzle.db) {
return db.insert(collections).values(data).returning().then(takeFirstOrThrow) return db.insert(collections).values(data).returning().then(takeFirstOrThrow)
} }

View file

@ -7,6 +7,7 @@ import { takeFirstOrThrow } from '../common/utils/repository'
export type CreateCredentials = InferInsertModel<typeof credentialsTable> export type CreateCredentials = InferInsertModel<typeof credentialsTable>
export type UpdateCredentials = Partial<CreateCredentials> export type UpdateCredentials = Partial<CreateCredentials>
export type DeleteCredentials = Pick<CreateCredentials, 'id'>
@injectable() @injectable()
export class CredentialsRepository { export class CredentialsRepository {
@ -43,7 +44,7 @@ export class CredentialsRepository {
} }
async findOneByIdOrThrow(id: string, db = this.drizzle.db) { async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const credentials = await this.findOneById(id) const credentials = await this.findOneById(id, db)
if (!credentials) throw Error('Credentials not found') if (!credentials) throw Error('Credentials not found')
return credentials return credentials
} }

View file

@ -0,0 +1,28 @@
import { type InferInsertModel, and, eq } from 'drizzle-orm'
import { inject, injectable } from 'tsyringe'
import { takeFirstOrThrow } from '../common/utils/repository'
import { federatedIdentityTable } from '../databases/tables'
import { DrizzleService } from '../services/drizzle.service'
export type CreateFederatedIdentity = InferInsertModel<typeof federatedIdentityTable>
@injectable()
export class FederatedIdentityRepository {
constructor(@inject(DrizzleService) private readonly drizzle: DrizzleService) {}
async findOneByUserIdAndProvider(userId: string, provider: string) {
return this.drizzle.db.query.federatedIdentityTable.findFirst({
where: and(eq(federatedIdentityTable.user_id, userId), eq(federatedIdentityTable.identity_provider, provider)),
})
}
async findOneByFederatedUserIdAndProvider(federatedUserId: string, provider: string) {
return this.drizzle.db.query.federatedIdentityTable.findFirst({
where: and(eq(federatedIdentityTable.federated_user_id, federatedUserId), eq(federatedIdentityTable.identity_provider, provider)),
})
}
async create(data: CreateFederatedIdentity, db = this.drizzle.db) {
return db.insert(federatedIdentityTable).values(data).returning().then(takeFirstOrThrow)
}
}

View file

@ -28,29 +28,29 @@ export class RolesRepository {
constructor(@inject(DrizzleService) private readonly drizzle: DrizzleService) {} constructor(@inject(DrizzleService) private readonly drizzle: DrizzleService) {}
async findOneById(id: string, db = this.drizzle.db) { async findOneById(id: string, db = this.drizzle.db) {
return db.query.roles.findFirst({ return db.query.rolesTable.findFirst({
where: eq(rolesTable.id, id), where: eq(rolesTable.id, id),
}) })
} }
async findOneByIdOrThrow(id: string, db = this.drizzle.db) { async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const role = await this.findOneById(id) const role = await this.findOneById(id, db)
if (!role) throw Error('Role not found') if (!role) throw Error('Role not found')
return role return role
} }
async findAll(db = this.drizzle.db) { async findAll(db = this.drizzle.db) {
return db.query.roles.findMany() return db.query.rolesTable.findMany()
} }
async findOneByName(name: string, db = this.drizzle.db) { async findOneByName(name: string, db = this.drizzle.db) {
return db.query.roles.findFirst({ return db.query.rolesTable.findFirst({
where: eq(rolesTable.name, name), where: eq(rolesTable.name, name),
}) })
} }
async findOneByNameOrThrow(name: string, db = this.drizzle.db) { async findOneByNameOrThrow(name: string, db = this.drizzle.db) {
const role = await this.findOneByName(name) const role = await this.findOneByName(name, db)
if (!role) throw Error('Role not found') if (!role) throw Error('Role not found')
return role return role
} }

View file

@ -33,8 +33,8 @@ export class UserRolesRepository {
}) })
} }
async findOneByIdOrThrow(id: string) { async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const userRole = await this.findOneById(id) const userRole = await this.findOneById(id, db)
if (!userRole) throw Error('User not found') if (!userRole) throw Error('User not found')
return userRole return userRole
} }

View file

@ -1,37 +1,50 @@
import { inject, injectable } from "tsyringe"; import type { db } from '$lib/server/api/packages/drizzle'
import { generateRandomAnimalName } from "$lib/utils/randomDataUtil"; import { generateRandomAnimalName } from '$lib/utils/randomDataUtil'
import { CollectionsRepository } from "../repositories/collections.repository"; import { inject, injectable } from 'tsyringe'
import { CollectionsRepository } from '../repositories/collections.repository'
@injectable() @injectable()
export class CollectionsService { export class CollectionsService {
constructor( constructor(@inject(CollectionsRepository) private readonly collectionsRepository: CollectionsRepository) {}
@inject(CollectionsRepository) private readonly collectionsRepository: CollectionsRepository
) { }
async findOneByUserId(userId: string) { async findOneByUserId(userId: string) {
return this.collectionsRepository.findOneByUserId(userId); return this.collectionsRepository.findOneByUserId(userId)
} }
async findAllByUserId(userId: string) { async findAllByUserId(userId: string) {
return this.collectionsRepository.findAllByUserId(userId); return this.collectionsRepository.findAllByUserId(userId)
}
async findAllByUserIdWithDetails(userId: string) {
return this.collectionsRepository.findAllByUserIdWithDetails(userId)
} }
async findOneById(id: string) { async findOneById(id: string) {
return this.collectionsRepository.findOneById(id); return this.collectionsRepository.findOneById(id)
} }
async findOneByCuid(cuid: string) { async findOneByCuid(cuid: string) {
return this.collectionsRepository.findOneByCuid(cuid); return this.collectionsRepository.findOneByCuid(cuid)
} }
async createEmptyNoName(userId: string) { async createEmptyNoName(userId: string, trx: Parameters<Parameters<typeof db.transaction>[0]>[0] | null = null) {
return this.createEmpty(userId, null); return this.createEmpty(userId, null, trx)
} }
async createEmpty(userId: string, name: string | null) { async createEmpty(userId: string, name: string | null, trx: Parameters<Parameters<typeof db.transaction>[0]>[0] | null = null) {
return this.collectionsRepository.create({ if (!trx) {
user_id: userId, return this.collectionsRepository.create({
name: name ?? generateRandomAnimalName(), user_id: userId,
}); name: name ?? generateRandomAnimalName(),
})
}
return this.collectionsRepository.create(
{
user_id: userId,
name: name ?? generateRandomAnimalName(),
},
trx,
)
} }
} }

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'
@ -42,13 +44,13 @@ export class IamService {
} }
const existingUserForNewUsername = await this.usersService.findOneByUsername(data.username) const existingUserForNewUsername = await this.usersService.findOneByUsername(data.username)
if (existingUserForNewUsername && existingUserForNewUsername.id !== userId) { if (existingUserForNewUsername && existingUserForNewUsername.id !== user.id) {
return { return {
error: 'Username already in use', error: 'Username already in use',
} }
} }
return this.usersService.updateUser(userId, { return this.usersService.updateUser(user.id, {
first_name: data.firstName, first_name: data.firstName,
last_name: data.lastName, last_name: data.lastName,
username: data.username !== user.username ? data.username : user.username, username: data.username !== user.username ? data.username : user.username,
@ -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

@ -0,0 +1,27 @@
import { inject, injectable } from 'tsyringe'
import { FederatedIdentityRepository } from '../repositories/federated_identity.repository'
import { UsersService } from './users.service'
import type {OAuthUser, OAuthProviders} from "$lib/server/api/common/types/oauth";
@injectable()
export class OAuthService {
constructor(
@inject(FederatedIdentityRepository) private readonly federatedIdentityRepository: FederatedIdentityRepository,
@inject(UsersService) private readonly usersService: UsersService,
) {}
async handleOAuthUser(oAuthUser: OAuthUser, oauthProvider: OAuthProviders) {
const federatedUser = await this.federatedIdentityRepository.findOneByFederatedUserIdAndProvider(oAuthUser.sub, oauthProvider)
if (federatedUser) {
return federatedUser.user_id
}
const user = await this.usersService.createOAuthUser(oAuthUser, oauthProvider)
if (!user) {
throw new Error('Failed to create user')
}
return user.id
}
}

View file

@ -1,39 +1,51 @@
import {inject, injectable} from "tsyringe"; import type { db } from '$lib/server/api/packages/drizzle'
import {type CreateUserRole, UserRolesRepository} from "$lib/server/api/repositories/user_roles.repository"; import { type CreateUserRole, UserRolesRepository } from '$lib/server/api/repositories/user_roles.repository'
import {RolesService} from "$lib/server/api/services/roles.service"; import { RolesService } from '$lib/server/api/services/roles.service'
import { inject, injectable } from 'tsyringe'
@injectable() @injectable()
export class UserRolesService { export class UserRolesService {
constructor( constructor(
@inject(UserRolesRepository) private readonly userRolesRepository: UserRolesRepository, @inject(UserRolesRepository) private readonly userRolesRepository: UserRolesRepository,
@inject(RolesService) private readonly rolesService: RolesService @inject(RolesService) private readonly rolesService: RolesService,
) { } ) {}
async findOneById(id: string) { async findOneById(id: string) {
return this.userRolesRepository.findOneById(id); return this.userRolesRepository.findOneById(id)
} }
async findAllByUserId(userId: string) { async findAllByUserId(userId: string) {
return this.userRolesRepository.findAllByUserId(userId); return this.userRolesRepository.findAllByUserId(userId)
} }
async create(data: CreateUserRole) { async create(data: CreateUserRole) {
return this.userRolesRepository.create(data); return this.userRolesRepository.create(data)
} }
async addRoleToUser(userId: string, roleName: string, primary = false) { async addRoleToUser(userId: string, roleName: string, primary = false, trx: Parameters<Parameters<typeof db.transaction>[0]>[0] | null = null) {
// Find the role by its name // Find the role by its name
const role = await this.rolesService.findOneByNameOrThrow(roleName); const role = await this.rolesService.findOneByNameOrThrow(roleName)
if (!role || !role.id) { if (!role || !role.id) {
throw new Error(`Role with name ${roleName} not found`); throw new Error(`Role with name ${roleName} not found`)
}
if (!trx) {
return this.userRolesRepository.create({
user_id: userId,
role_id: role.id,
primary,
})
} }
// Create a UserRole entry linking the user and the role // Create a UserRole entry linking the user and the role
return this.userRolesRepository.create({ return this.userRolesRepository.create(
user_id: userId, {
role_id: role.id, user_id: userId,
primary, role_id: role.id,
}); primary,
},
trx,
)
} }
} }

View file

@ -1,21 +1,28 @@
import type { SignupUsernameEmailDto } from '$lib/server/api/dtos/signup-username-email.dto' import type { SignupUsernameEmailDto } from '$lib/server/api/dtos/signup-username-email.dto'
import { CredentialsRepository } from '$lib/server/api/repositories/credentials.repository' import { CredentialsRepository } from '$lib/server/api/repositories/credentials.repository'
import { FederatedIdentityRepository } from '$lib/server/api/repositories/federated_identity.repository'
import { WishlistsRepository } from '$lib/server/api/repositories/wishlists.repository'
import { TokensService } from '$lib/server/api/services/tokens.service' import { TokensService } from '$lib/server/api/services/tokens.service'
import { UserRolesService } from '$lib/server/api/services/user_roles.service' import { UserRolesService } from '$lib/server/api/services/user_roles.service'
import { inject, injectable } from 'tsyringe' import { inject, injectable } from 'tsyringe'
import { CredentialsType } from '../databases/tables' import {CredentialsType, RoleName} from '../databases/tables'
import { type UpdateUser, UsersRepository } from '../repositories/users.repository' import { type UpdateUser, UsersRepository } from '../repositories/users.repository'
import { CollectionsService } from './collections.service' import { CollectionsService } from './collections.service'
import { DrizzleService } from './drizzle.service'
import { WishlistsService } from './wishlists.service' import { WishlistsService } from './wishlists.service'
import type {OAuthUser} from "$lib/server/api/common/types/oauth";
@injectable() @injectable()
export class UsersService { export class UsersService {
constructor( constructor(
@inject(CollectionsService) private readonly collectionsService: CollectionsService, @inject(CollectionsService) private readonly collectionsService: CollectionsService,
@inject(CredentialsRepository) private readonly credentialsRepository: CredentialsRepository, @inject(CredentialsRepository) private readonly credentialsRepository: CredentialsRepository,
@inject(DrizzleService) private readonly drizzleService: DrizzleService,
@inject(FederatedIdentityRepository) private readonly federatedIdentityRepository: FederatedIdentityRepository,
@inject(TokensService) private readonly tokenService: TokensService, @inject(TokensService) private readonly tokenService: TokensService,
@inject(UsersRepository) private readonly usersRepository: UsersRepository, @inject(UsersRepository) private readonly usersRepository: UsersRepository,
@inject(UserRolesService) private readonly userRolesService: UserRolesService, @inject(UserRolesService) private readonly userRolesService: UserRolesService,
@inject(WishlistsRepository) private readonly wishlistsRepository: WishlistsRepository,
@inject(WishlistsService) private readonly wishlistsService: WishlistsService, @inject(WishlistsService) private readonly wishlistsService: WishlistsService,
) {} ) {}
@ -23,34 +30,76 @@ export class UsersService {
const { firstName, lastName, email, username, password } = data const { firstName, lastName, email, username, password } = data
const hashedPassword = await this.tokenService.createHashedToken(password) const hashedPassword = await this.tokenService.createHashedToken(password)
const user = await this.usersRepository.create({ return await this.drizzleService.db.transaction(async (trx) => {
first_name: firstName, const createdUser = await this.usersRepository.create(
last_name: lastName, {
email, first_name: firstName,
username, last_name: lastName,
email,
username,
},
trx,
)
if (!createdUser) {
return null
}
const credentials = await this.credentialsRepository.create(
{
user_id: createdUser.id,
type: CredentialsType.PASSWORD,
secret_data: hashedPassword,
},
trx,
)
if (!credentials) {
await this.usersRepository.delete(createdUser.id)
return null
}
await this.userRolesService.addRoleToUser(createdUser.id, RoleName.USER, true, trx)
await this.wishlistsService.createEmptyNoName(createdUser.id, trx)
await this.collectionsService.createEmptyNoName(createdUser.id, trx)
}) })
}
if (!user) { async createOAuthUser(oAuthUser: OAuthUser, oauthProvider: string) {
return null return await this.drizzleService.db.transaction(async (trx) => {
} const createdUser = await this.usersRepository.create(
{
username: oAuthUser.username || oAuthUser.username,
email: oAuthUser.email || null,
first_name: oAuthUser.given_name || null,
last_name: oAuthUser.family_name || null,
picture: oAuthUser.picture || null,
email_verified: oAuthUser.email_verified || false,
},
trx,
)
const credentials = await this.credentialsRepository.create({ if (!createdUser) {
user_id: user.id, return null
type: CredentialsType.PASSWORD, }
secret_data: hashedPassword,
await this.federatedIdentityRepository.create(
{
identity_provider: oauthProvider,
user_id: createdUser.id,
federated_user_id: oAuthUser.sub,
federated_username: oAuthUser.email || oAuthUser.username,
},
trx,
)
await this.userRolesService.addRoleToUser(createdUser.id, RoleName.USER, true, trx)
await this.wishlistsService.createEmptyNoName(createdUser.id, trx)
await this.collectionsService.createEmptyNoName(createdUser.id, trx)
return createdUser
}) })
if (!credentials) {
await this.usersRepository.delete(user.id)
return null
}
await this.userRolesService.addRoleToUser(user.id, 'user', true)
await this.wishlistsService.createEmptyNoName(user.id)
await this.collectionsService.createEmptyNoName(user.id)
return user
} }
async updateUser(userId: string, data: UpdateUser) { async updateUser(userId: string, data: UpdateUser) {
@ -69,6 +118,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,34 +1,41 @@
import { inject, injectable } from "tsyringe"; import type { db } from '$lib/server/api/packages/drizzle'
import { WishlistsRepository } from "../repositories/wishlists.repository"; import { generateRandomAnimalName } from '$lib/utils/randomDataUtil'
import { generateRandomAnimalName } from "$lib/utils/randomDataUtil"; import { inject, injectable } from 'tsyringe'
import { WishlistsRepository } from '../repositories/wishlists.repository'
@injectable() @injectable()
export class WishlistsService { export class WishlistsService {
constructor(@inject(WishlistsRepository) private readonly wishlistsRepository: WishlistsRepository) {}
constructor(
@inject(WishlistsRepository) private readonly wishlistsRepository: WishlistsRepository
) { }
async findAllByUserId(userId: string) { async findAllByUserId(userId: string) {
return this.wishlistsRepository.findAllByUserId(userId); return this.wishlistsRepository.findAllByUserId(userId)
} }
async findOneById(id: string) { async findOneById(id: string) {
return this.wishlistsRepository.findOneById(id); return this.wishlistsRepository.findOneById(id)
} }
async findOneByCuid(cuid: string) { async findOneByCuid(cuid: string) {
return this.wishlistsRepository.findOneByCuid(cuid); return this.wishlistsRepository.findOneByCuid(cuid)
} }
async createEmptyNoName(userId: string) { async createEmptyNoName(userId: string, trx: Parameters<Parameters<typeof db.transaction>[0]>[0] | null = null) {
return this.createEmpty(userId, null); return this.createEmpty(userId, null, trx)
} }
async createEmpty(userId: string, name: string | null) { async createEmpty(userId: string, name: string | null, trx: Parameters<Parameters<typeof db.transaction>[0]>[0] | null = null) {
return this.wishlistsRepository.create({ if (!trx) {
user_id: userId, return this.wishlistsRepository.create({
name: name ?? generateRandomAnimalName(), user_id: userId,
}); name: name ?? generateRandomAnimalName(),
})
}
return this.wishlistsRepository.create(
{
user_id: userId,
name: name ?? generateRandomAnimalName(),
},
trx,
)
} }
} }

View file

@ -1,32 +1,38 @@
import 'reflect-metadata'; import 'reflect-metadata'
import { container } from 'tsyringe'; import { container } from 'tsyringe'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { HashingService } from '../services/hashing.service'; import { HashingService } from '../services/hashing.service'
describe('HashingService', () => { describe('HashingService', () => {
let service: HashingService; let service: HashingService
beforeAll(() => { beforeAll(() => {
service = container.resolve(HashingService); service = container.resolve(HashingService)
}); })
afterAll(() => { afterAll(() => {
vi.resetAllMocks() vi.resetAllMocks()
}); })
describe('Create Hash', () => { describe('Create Hash', () => {
it('should create a hash', async () => { it('should create a hash', async () => {
const hash = await service.hash('111'); const hash = await service.hash('111')
expect(hash).not.toBeUndefined(); expect(hash).not.toBeUndefined()
expect(hash).not.toBeNull(); expect(hash).not.toBeNull()
}); })
}) })
describe('Verify Hash', () => { describe('Verify Hash', () => {
it('should verify a hash', async () => { it('should verify a hash', async () => {
const hash = await service.hash('111'); const hash = await service.hash('111')
const verifiable = await service.verify(hash, '111'); const verifiable = await service.verify(hash, '111')
expect(verifiable).toBeTruthy(); expect(verifiable).toBeTruthy()
}); })
it('should not verify a hash', async () => {
const hash = await service.hash('111')
const verifiable = await service.verify(hash, '222')
expect(verifiable).toBeFalsy()
})
}) })
}) })

View file

@ -0,0 +1,124 @@
import 'reflect-metadata'
import { IamService } from '$lib/server/api/services/iam.service'
import { LuciaService } from '$lib/server/api/services/lucia.service'
import { UsersService } from '$lib/server/api/services/users.service'
import { faker } from '@faker-js/faker'
import { container } from 'tsyringe'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
describe('IamService', () => {
let service: IamService
const luciaService = vi.mocked(LuciaService.prototype)
const userService = vi.mocked(UsersService.prototype)
beforeAll(() => {
service = container
.register<LuciaService>(LuciaService, { useValue: luciaService })
.register<UsersService>(UsersService, { useValue: userService })
.resolve(IamService)
})
beforeEach(() => {
vi.resetAllMocks()
})
afterAll(() => {
vi.resetAllMocks()
})
const timeStampDate = new Date()
const dbUser = {
id: faker.string.uuid(),
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
first_name: faker.person.firstName(),
last_name: faker.person.lastName(),
email: faker.internet.email(),
username: faker.internet.userName(),
verified: false,
receive_email: false,
mfa_enabled: false,
theme: 'system',
createdAt: timeStampDate,
updatedAt: timeStampDate,
}
describe('Update Profile', () => {
it('should update user', async () => {
userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser)
userService.findOneByUsername = vi.fn().mockResolvedValue(undefined)
userService.updateUser = vi.fn().mockResolvedValue(dbUser)
const spy_userService_findOneById = vi.spyOn(userService, 'findOneById')
const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername')
const spy_userService_updateUser = vi.spyOn(userService, 'updateUser')
await expect(
service.updateProfile(faker.string.uuid(), {
username: faker.internet.userName(),
}),
).resolves.toEqual(dbUser)
expect(spy_userService_findOneById).toBeCalledTimes(1)
expect(spy_userService_findOneByUsername).toBeCalledTimes(1)
expect(spy_userService_updateUser).toBeCalledTimes(1)
})
it('should error on no user found', async () => {
userService.findOneById = vi.fn().mockResolvedValueOnce(undefined)
const spy_userService_findOneById = vi.spyOn(userService, 'findOneById')
const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername')
const spy_userService_updateUser = vi.spyOn(userService, 'updateUser')
await expect(
service.updateProfile(faker.string.uuid(), {
username: faker.internet.userName(),
}),
).resolves.toEqual({
error: 'User not found',
})
expect(spy_userService_findOneById).toBeCalledTimes(1)
expect(spy_userService_findOneByUsername).toBeCalledTimes(0)
expect(spy_userService_updateUser).toBeCalledTimes(0)
})
it('should error on duplicate username', async () => {
userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser)
userService.findOneByUsername = vi.fn().mockResolvedValue({
id: faker.string.uuid(),
})
userService.updateUser = vi.fn().mockResolvedValue(dbUser)
const spy_userService_findOneById = vi.spyOn(userService, 'findOneById')
const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername')
const spy_userService_updateUser = vi.spyOn(userService, 'updateUser')
await expect(
service.updateProfile(faker.string.uuid(), {
username: faker.internet.userName(),
}),
).resolves.toEqual({
error: 'Username already in use',
})
expect(spy_userService_findOneById).toBeCalledTimes(1)
expect(spy_userService_findOneByUsername).toBeCalledTimes(1)
expect(spy_userService_updateUser).toBeCalledTimes(0)
})
})
it('should not error if the user id of new username is the current user id', async () => {
userService.findOneById = vi.fn().mockResolvedValueOnce(dbUser)
userService.findOneByUsername = vi.fn().mockResolvedValue({
id: dbUser.id,
})
userService.updateUser = vi.fn().mockResolvedValue(dbUser)
const spy_userService_findOneById = vi.spyOn(userService, 'findOneById')
const spy_userService_findOneByUsername = vi.spyOn(userService, 'findOneByUsername')
const spy_userService_updateUser = vi.spyOn(userService, 'updateUser')
await expect(
service.updateProfile(dbUser.id, {
username: dbUser.id,
}),
).resolves.toEqual(dbUser)
expect(spy_userService_findOneById).toBeCalledTimes(1)
expect(spy_userService_findOneByUsername).toBeCalledTimes(1)
expect(spy_userService_updateUser).toBeCalledTimes(1)
})
})

View file

@ -1,49 +1,45 @@
import 'reflect-metadata'; import 'reflect-metadata'
import { container } from 'tsyringe'; import { Argon2id } from 'oslo/password'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { container } from 'tsyringe'
import { TokensService } from '../services/tokens.service'; import { afterAll, beforeAll, describe, expect, expectTypeOf, it, vi } from 'vitest'
import { HashingService } from '../services/hashing.service'; import { HashingService } from '../services/hashing.service'
import { Argon2id } from 'oslo/password'; import { TokensService } from '../services/tokens.service'
describe('TokensService', () => { describe('TokensService', () => {
let service: TokensService; let service: TokensService
const hashingService = vi.mocked(HashingService.prototype); const hashingService = vi.mocked(HashingService.prototype)
beforeAll(() => { beforeAll(() => {
service = container service = container.register<HashingService>(HashingService, { useValue: hashingService }).resolve(TokensService)
.register<HashingService>(HashingService, { useValue: hashingService }) })
.resolve(TokensService);
});
afterAll(() => { afterAll(() => {
vi.resetAllMocks() vi.resetAllMocks()
}); })
describe('Generate Token', () => { describe('Generate Token', () => {
const hashedPassword = new Argon2id().hash('111');
hashingService.hash = vi.fn().mockResolvedValue(hashedPassword);
hashingService.verify = vi.fn().mockResolvedValue(true);
const spy_hashingService_hash = vi.spyOn(hashingService, 'hash');
const spy_hashingService_verify = vi.spyOn(hashingService, 'verify');
it('should resolve', async () => { it('should resolve', async () => {
await expect(service.createHashedToken('111')).resolves.string const hashedPassword = await new Argon2id().hash('111')
hashingService.hash = vi.fn().mockResolvedValue(hashedPassword)
const spy_hashingService_hash = vi.spyOn(hashingService, 'hash')
const spy_hashingService_verify = vi.spyOn(hashingService, 'verify')
await expectTypeOf(service.createHashedToken('111')).resolves.toBeString()
expect(spy_hashingService_hash).toBeCalledTimes(1)
expect(spy_hashingService_verify).toBeCalledTimes(0)
}) })
it('should generate a token that is verifiable', async () => { it('should generate a token that is verifiable', async () => {
const token = await service.createHashedToken('111'); hashingService.hash = vi.fn().mockResolvedValue(await new Argon2id().hash('111'))
expect(token).not.toBeUndefined(); hashingService.verify = vi.fn().mockResolvedValue(true)
expect(token).not.toBeNull(); const spy_hashingService_hash = vi.spyOn(hashingService, 'hash')
const verifiable = await service.verifyHashedToken(token, '111'); const spy_hashingService_verify = vi.spyOn(hashingService, 'verify')
expect(verifiable).toBeTruthy(); const token = await service.createHashedToken('111')
}); expect(token).not.toBeNaN()
expect(token).not.toBeUndefined()
it('should generate a hashed token', async () => { expect(token).not.toBeNull()
expect(spy_hashingService_hash).toHaveBeenCalledTimes(2); const verifiable = await service.verifyHashedToken(token, '111')
expect(verifiable).toBeTruthy()
expect(spy_hashingService_hash).toBeCalledTimes(1)
expect(spy_hashingService_verify).toBeCalledTimes(1)
}) })
it('should verify a hashed token', async () => { })
expect(spy_hashingService_verify).toHaveBeenCalledTimes(1); })
})
});
});

View file

@ -1,62 +1,75 @@
import 'reflect-metadata'; import 'reflect-metadata'
import { container } from 'tsyringe'; import { RoleName } from '$lib/server/api/databases/tables'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { faker } from '@faker-js/faker'
import { UserRolesService } from '../services/user_roles.service'; import { container } from 'tsyringe'
import { UserRolesRepository } from '../repositories/user_roles.repository'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { RolesService } from '../services/roles.service'; import { UserRolesRepository } from '../repositories/user_roles.repository'
import { RolesService } from '../services/roles.service'
import { UserRolesService } from '../services/user_roles.service'
describe('UserRolesService', () => { describe('UserRolesService', () => {
let service: UserRolesService; let service: UserRolesService
const userRolesRepository = vi.mocked(UserRolesRepository.prototype); const userRolesRepository = vi.mocked(UserRolesRepository.prototype)
const rolesService = vi.mocked(RolesService.prototype); const rolesService = vi.mocked(RolesService.prototype)
beforeAll(() => { beforeAll(() => {
service = container service = container
.register<UserRolesRepository>(UserRolesRepository, { useValue: userRolesRepository }) .register<UserRolesRepository>(UserRolesRepository, { useValue: userRolesRepository })
.register<RolesService>(RolesService, { useValue: rolesService }) .register<RolesService>(RolesService, { useValue: rolesService })
.resolve(UserRolesService); .resolve(UserRolesService)
}); })
afterAll(() => { afterAll(() => {
vi.resetAllMocks() vi.resetAllMocks()
}); })
const timeStampDate = new Date()
const roleUUID = faker.string.uuid()
const userUUID = faker.string.uuid()
const dbRole = {
id: roleUUID,
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
name: RoleName.ADMIN,
createdAt: timeStampDate,
updatedAt: timeStampDate,
}
const dbUserRole = {
id: faker.string.uuid(),
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
role_id: roleUUID,
user_id: userUUID,
primary: true,
createdAt: timeStampDate,
updatedAt: timeStampDate,
}
describe('Create User Role', () => { describe('Create User Role', () => {
rolesService.findOneByNameOrThrow = vi.fn().mockResolvedValue({
id: '3e0e9f0f-0a0b-4f0b-8f0b-0a0b4f0b8f0b',
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
name: 'user',
createdAt: new Date(),
updatedAt: new Date()
} satisfies Awaited<ReturnType<typeof rolesService.findOneByNameOrThrow>>);
userRolesRepository.create = vi.fn().mockResolvedValue({
id: '3e0e9f0f-0a0b-4f0b-8f0b-0a0b4f0b8f0b',
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
user_id: '3e0e9f0f-0a0b-4f0b-8f0b-0a0b4f0b8fff',
role_id: '3e0e9f0f-0a0b-4f0b-8f0b-0a0b4f0b8f0b',
primary: true,
createdAt: new Date(),
updatedAt: new Date()
} satisfies Awaited<ReturnType<typeof userRolesRepository.create>>);
const spy_rolesService_findOneByNameOrThrow = vi.spyOn(rolesService, 'findOneByNameOrThrow');
const spy_userRolesRepository_create = vi.spyOn(userRolesRepository, 'create');
it('should resolve', async () => { it('should resolve', async () => {
await expect(service.addRoleToUser('3e0e9f0f-0a0b-4f0b-8f0b-0a0b4f0b8fff', 'user', true)).resolves.not.toThrowError(); rolesService.findOneByNameOrThrow = vi.fn().mockResolvedValue(dbRole satisfies Awaited<ReturnType<typeof rolesService.findOneByNameOrThrow>>)
})
it('should call rolesService.findOneByNameOrThrow', async () => { userRolesRepository.create = vi.fn().mockResolvedValue(dbUserRole satisfies Awaited<ReturnType<typeof userRolesRepository.create>>)
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledWith('user');
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledTimes(1); const spy_rolesService_findOneByNameOrThrow = vi.spyOn(rolesService, 'findOneByNameOrThrow')
}) const spy_userRolesRepository_create = vi.spyOn(userRolesRepository, 'create')
it('should call userRolesRepository.create', async () => {
await expect(service.addRoleToUser(userUUID, RoleName.ADMIN, true)).resolves.not.toThrowError()
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledWith(RoleName.ADMIN)
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledTimes(1)
expect(spy_userRolesRepository_create).toBeCalledWith({ expect(spy_userRolesRepository_create).toBeCalledWith({
user_id: '3e0e9f0f-0a0b-4f0b-8f0b-0a0b4f0b8fff', user_id: userUUID,
role_id: '3e0e9f0f-0a0b-4f0b-8f0b-0a0b4f0b8f0b', role_id: dbRole.id,
primary: true primary: true,
}); })
expect(spy_userRolesRepository_create).toBeCalledTimes(1); expect(spy_userRolesRepository_create).toBeCalledTimes(1)
})
it('should error on no role found', async () => {
rolesService.findOneByNameOrThrow = vi.fn().mockResolvedValue(undefined)
const spy_rolesService_findOneByNameOrThrow = vi.spyOn(rolesService, 'findOneByNameOrThrow')
await expect(service.addRoleToUser(userUUID, RoleName.ADMIN, true)).rejects.toThrowError(`Role with name ${RoleName.ADMIN} not found`)
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledWith(RoleName.ADMIN)
expect(spy_rolesService_findOneByNameOrThrow).toBeCalledTimes(1)
}) })
}) })
}); })

View file

@ -1,104 +1,130 @@
import 'reflect-metadata'; import 'reflect-metadata'
import { container } from 'tsyringe'; import { CredentialsType } from '$lib/server/api/databases/tables'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { faker } from '@faker-js/faker'
import { UsersService } from '../services/users.service'; import { Argon2id } from 'oslo/password'
import { CredentialsRepository } from '../repositories/credentials.repository'; import { container } from 'tsyringe'
import { TokensService } from '../services/tokens.service'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { UserRolesService } from '../services/user_roles.service'; import { CredentialsRepository } from '../repositories/credentials.repository'
import { UsersRepository } from '../repositories/users.repository'; import { UsersRepository } from '../repositories/users.repository'
import { Argon2id } from 'oslo/password'; import { CollectionsService } from '../services/collections.service'
import { WishlistsService } from '../services/wishlists.service'; import { TokensService } from '../services/tokens.service'
import { CollectionsService } from '../services/collections.service'; import { UserRolesService } from '../services/user_roles.service'
import { UsersService } from '../services/users.service'
import { WishlistsService } from '../services/wishlists.service'
describe('UsersService', () => { describe('UsersService', () => {
let service: UsersService; let service: UsersService
const credentialsRepository = vi.mocked(CredentialsRepository.prototype); const credentialsRepository = vi.mocked(CredentialsRepository.prototype)
const tokensService = vi.mocked(TokensService.prototype); const tokensService = vi.mocked(TokensService.prototype)
const usersRepository = vi.mocked(UsersRepository.prototype); const usersRepository = vi.mocked(UsersRepository.prototype)
const userRolesService = vi.mocked(UserRolesService.prototype); const userRolesService = vi.mocked(UserRolesService.prototype)
const wishlistsService = vi.mocked(WishlistsService.prototype); const wishlistsService = vi.mocked(WishlistsService.prototype)
const collectionsService = vi.mocked(CollectionsService.prototype); const collectionsService = vi.mocked(CollectionsService.prototype)
beforeAll(() => { beforeAll(() => {
service = container service = container
.register<CredentialsRepository>(CredentialsRepository, { useValue: credentialsRepository }) .register<CredentialsRepository>(CredentialsRepository, { useValue: credentialsRepository })
.register<TokensService>(TokensService, { useValue: tokensService }) .register<TokensService>(TokensService, { useValue: tokensService })
.register<UsersRepository>(UsersRepository, { useValue: usersRepository }) .register<UsersRepository>(UsersRepository, { useValue: usersRepository })
.register<UserRolesService>(UserRolesService, { useValue: userRolesService }) .register<UserRolesService>(UserRolesService, { useValue: userRolesService })
.register<WishlistsService>(WishlistsService, { useValue: wishlistsService }) .register<WishlistsService>(WishlistsService, { useValue: wishlistsService })
.register<CollectionsService>(CollectionsService, { useValue: collectionsService }) .register<CollectionsService>(CollectionsService, { useValue: collectionsService })
.resolve(UsersService); .resolve(UsersService)
}); })
afterAll(() => { afterAll(() => {
vi.resetAllMocks() vi.resetAllMocks()
}) })
const timeStampDate = new Date()
const dbUser = {
id: faker.string.uuid(),
cuid: 'ciglo1j8q0000t9j4xq8d6p5e',
first_name: faker.person.firstName(),
last_name: faker.person.lastName(),
email: faker.internet.email(),
username: faker.internet.userName(),
verified: false,
receive_email: false,
mfa_enabled: false,
theme: 'system',
createdAt: timeStampDate,
updatedAt: timeStampDate,
}
const dbCredentials = {
id: faker.string.uuid(),
user_id: dbUser.id,
type: CredentialsType.PASSWORD,
secret_data: 'hashedPassword',
createdAt: timeStampDate,
updatedAt: timeStampDate,
}
describe('Create User', () => { describe('Create User', () => {
const hashedPassword = new Argon2id().hash('111'); it('should resolve', async () => {
tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword) const hashedPassword = new Argon2id().hash('111')
tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword)
usersRepository.create = vi.fn().mockResolvedValue(dbUser satisfies Awaited<ReturnType<typeof usersRepository.create>>)
credentialsRepository.create = vi.fn().mockResolvedValue(dbCredentials satisfies Awaited<ReturnType<typeof credentialsRepository.create>>)
userRolesService.addRoleToUser = vi.fn().mockResolvedValue(undefined)
wishlistsService.createEmptyNoName = vi.fn().mockResolvedValue(undefined)
collectionsService.createEmptyNoName = vi.fn().mockResolvedValue(undefined)
usersRepository.create = vi.fn().mockResolvedValue({ const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken')
id: '3e0e9f0f-0a0b-4f0b-8f0b-0a0b4f0b8f0b', const spy_usersRepository_create = vi.spyOn(usersRepository, 'create')
cuid: 'ciglo1j8q0000t9j4xq8d6p5e', const spy_credentialsRepository_create = vi.spyOn(credentialsRepository, 'create')
first_name: 'test', const spy_userRolesService_addRoleToUser = vi.spyOn(userRolesService, 'addRoleToUser')
last_name: 'test', const spy_wishlistsService_createEmptyNoName = vi.spyOn(wishlistsService, 'createEmptyNoName')
email: 'test@example.com', const spy_collectionsService_createEmptyNoName = vi.spyOn(collectionsService, 'createEmptyNoName')
username: 'test', await expect(
verified: false, service.create({
receive_email: false, firstName: faker.person.firstName(),
theme: 'system', lastName: faker.person.lastName(),
createdAt: new Date(), email: faker.internet.email(),
updatedAt: new Date() username: faker.internet.userName(),
} satisfies Awaited<ReturnType<typeof usersRepository.create>>) password: faker.string.alphanumeric(10),
confirm_password: faker.string.alphanumeric(10),
credentialsRepository.create = vi.fn().mockResolvedValue({ }),
id: '3e0e9f0f-0a0b-4f0b-8f0b-0a0b4f0b8f0b', ).resolves.toEqual(dbUser)
user_id: '3e0e9f0f-0a0b-4f0b-8f0b-0a0b4f0b8f0b', expect(spy_tokensService_createHashToken).toBeCalledTimes(1)
type: 'PASSWORD', expect(spy_usersRepository_create).toBeCalledTimes(1)
secret_data: hashedPassword expect(spy_credentialsRepository_create).toBeCalledTimes(1)
})
userRolesService.addRoleToUser = vi.fn().mockResolvedValue(undefined)
wishlistsService.createEmptyNoName = vi.fn().mockResolvedValue(undefined)
collectionsService.createEmptyNoName = vi.fn().mockResolvedValue(undefined)
const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken');
const spy_usersRepository_create = vi.spyOn(usersRepository, 'create');
const spy_credentialsRepository_create = vi.spyOn(credentialsRepository, 'create');
const spy_userRolesService_addRoleToUser = vi.spyOn(userRolesService, 'addRoleToUser');
const spy_wishlistsService_createEmptyNoName = vi.spyOn(wishlistsService, 'createEmptyNoName');
const spy_collectionsService_createEmptyNoName = vi.spyOn(collectionsService, 'createEmptyNoName');
it('should resolve', async () => {
await expect(service.create({
firstName: 'test',
lastName: 'test',
email: 'test@example.com',
username: 'test',
password: '111',
confirm_password: '111'
})).resolves.not.toThrow()
})
it('should generate a hashed token', async () => {
expect(spy_tokensService_createHashToken).toBeCalledTimes(1)
})
it('should create a new user', async () => {
expect(spy_usersRepository_create).toHaveBeenCalledTimes(1)
})
it('should create a new credential', async () => {
expect(spy_credentialsRepository_create).toBeCalledTimes(1)
})
it('should add role to user', async () => {
expect(spy_userRolesService_addRoleToUser).toBeCalledTimes(1) expect(spy_userRolesService_addRoleToUser).toBeCalledTimes(1)
})
it('should create a new wishlist', async () => {
expect(spy_wishlistsService_createEmptyNoName).toBeCalledTimes(1) expect(spy_wishlistsService_createEmptyNoName).toBeCalledTimes(1)
})
it('should create a new collection', async () => {
expect(spy_collectionsService_createEmptyNoName).toBeCalledTimes(1) expect(spy_collectionsService_createEmptyNoName).toBeCalledTimes(1)
}) })
}) })
}); describe('Update User', () => {
it('should resolve Password Exiting Credentials', async () => {
const hashedPassword = new Argon2id().hash('111')
tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword)
credentialsRepository.update = vi.fn().mockResolvedValue(dbCredentials satisfies Awaited<ReturnType<typeof credentialsRepository.update>>)
credentialsRepository.findPasswordCredentialsByUserId = vi
.fn()
.mockResolvedValue(dbCredentials satisfies Awaited<ReturnType<typeof credentialsRepository.findPasswordCredentialsByUserId>>)
const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken')
const spy_credentialsRepository_findPasswordCredentialsByUserId = vi.spyOn(credentialsRepository, 'findPasswordCredentialsByUserId')
const spy_credentialsRepository_update = vi.spyOn(credentialsRepository, 'update')
await expect(service.updatePassword(dbUser.id, faker.string.alphanumeric(10))).resolves.toBeUndefined()
expect(spy_tokensService_createHashToken).toBeCalledTimes(1)
expect(spy_credentialsRepository_findPasswordCredentialsByUserId).toBeCalledTimes(1)
expect(spy_credentialsRepository_update).toBeCalledTimes(1)
})
it('Should Create User Password No Existing Credentials', async () => {
const hashedPassword = new Argon2id().hash('111')
tokensService.createHashedToken = vi.fn().mockResolvedValue(hashedPassword)
credentialsRepository.findPasswordCredentialsByUserId = vi.fn().mockResolvedValue(null)
credentialsRepository.create = vi.fn().mockResolvedValue(dbCredentials satisfies Awaited<ReturnType<typeof credentialsRepository.create>>)
const spy_tokensService_createHashToken = vi.spyOn(tokensService, 'createHashedToken')
const spy_credentialsRepository_create = vi.spyOn(credentialsRepository, 'create')
const spy_credentialsRepository_findPasswordCredentialsByUserId = vi.spyOn(credentialsRepository, 'findPasswordCredentialsByUserId')
await expect(service.updatePassword(dbUser.id, faker.string.alphanumeric(10))).resolves.not.toThrow()
expect(spy_tokensService_createHashToken).toBeCalledTimes(1)
expect(spy_credentialsRepository_findPasswordCredentialsByUserId).toBeCalledTimes(1)
expect(spy_credentialsRepository_create).toHaveBeenCalledTimes(1)
})
})
})

6
src/lib/server/auth.ts Normal file
View file

@ -0,0 +1,6 @@
import env from "$lib/server/api/common/env";
import { GitHub, Google } from "arctic";
export const github = new GitHub(env.GITHUB_CLIENT_ID, env.GITHUB_CLIENT_SECRET);
export const google = new Google(env.GOOGLE_CLIENT_ID, env.GOOGLE_CLIENT_SECRET, `${env.ORIGIN}/auth/callback/google`);

View file

@ -1,13 +1,12 @@
import { notSignedInMessage } from '$lib/flashMessages' import { notSignedInMessage } from '$lib/flashMessages'
import { collection_items, collections, gamesTable } from '$lib/server/api/databases/tables'
import { db } from '$lib/server/api/packages/drizzle' import { db } from '$lib/server/api/packages/drizzle'
import { userNotAuthenticated } from '$lib/server/auth-utils'
import { modifyListGameSchema } from '$lib/validations/zod-schemas' import { modifyListGameSchema } from '$lib/validations/zod-schemas'
import { type Actions, error, fail } from '@sveltejs/kit' import { type Actions, error, fail } from '@sveltejs/kit'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { redirect } from 'sveltekit-flash-message/server' import { redirect } from 'sveltekit-flash-message/server'
import { zod } from 'sveltekit-superforms/adapters' import { zod } from 'sveltekit-superforms/adapters'
import { superValidate } from 'sveltekit-superforms/server' import { superValidate } from 'sveltekit-superforms/server'
import { collection_items, collections, gamesTable } from '../../../../lib/server/api/databases/tables'
export async function load(event) { export async function load(event) {
const { locals } = event const { locals } = event
@ -18,23 +17,10 @@ export async function load(event) {
} }
try { try {
const userCollections = await db.query.collections.findMany({ const { data, error } = await locals.api.collections.$get().then(locals.parseApiResponse)
columns: {
cuid: true,
name: true,
created_at: true,
},
where: eq(collections.user_id, authedUser.id),
})
console.log('collections', userCollections)
if (userCollections?.length === 0) {
console.log('Collection was not found')
return fail(404, {})
}
return { return {
collections: userCollections, collections: data?.collections || [],
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)

View file

@ -1,24 +1,34 @@
<script lang="ts"> <script lang="ts">
const { data } = $props(); import * as Card from '$components/ui/card'
let collections = data?.collections || []; const { data } = $props()
let collections = data?.collections || []
</script> </script>
<svelte:head> <svelte:head>
<title>Your Collections | Bored Game</title> <title>Your Collections | Bored Game</title>
</svelte:head> </svelte:head>
<div class="container">
<h1>Your Collections</h1> <h1>Your Collections</h1>
<div class="collections">
<div class="collection-list"> <div class="collection-list">
{#if collections.length === 0} {#if collections.length === 0}
<h2>You have no collections</h2> <h2>You have no collections</h2>
{:else} {:else}
{#each collections as collection} {#each collections as collection}
<div class="collection grid gap-0.5"> <Card.Root>
<Card.Header>
<Card.Title>{collection.name}</Card.Title>
</Card.Header>
<Card.Content>
<p>Number of items:</p>
<p>Created at: {new Date(collection.createdAt).toLocaleString()}</p>
</Card.Content>
</Card.Root>
<!-- <div class="collection grid gap-0.5">
<h2><a href="/collections/{collection.cuid}">{collection.name}</a></h2> <h2><a href="/collections/{collection.cuid}">{collection.name}</a></h2>
<h3>Created at: {new Date(collection.created_at).toLocaleString()}</h3> <h3>Created at: {new Date(collection.createdAt).toLocaleString()}</h3>
</div> </div> -->
{/each} {/each}
{/if} {/if}
</div> </div>
@ -30,10 +40,6 @@
width: 100%; width: 100%;
} }
.collections {
margin: 2rem 0;
}
.collection-list { .collection-list {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(200px, 1fr)); grid-template-columns: repeat(3, minmax(200px, 1fr));

View file

@ -1,13 +1,12 @@
import { notSignedInMessage } from '$lib/flashMessages.js' import { notSignedInMessage } from '$lib/flashMessages.js'
import { collection_items, collections, gamesTable } from '$lib/server/api/databases/tables'
import { db } from '$lib/server/api/packages/drizzle' import { db } from '$lib/server/api/packages/drizzle'
import { userNotAuthenticated } from '$lib/server/auth-utils'
import { modifyListGameSchema } from '$lib/validations/zod-schemas' import { modifyListGameSchema } from '$lib/validations/zod-schemas'
import { type Actions, error, fail } from '@sveltejs/kit' import { type Actions, error } from '@sveltejs/kit'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { redirect } from 'sveltekit-flash-message/server' import { redirect } from 'sveltekit-flash-message/server'
import { zod } from 'sveltekit-superforms/adapters' import { zod } from 'sveltekit-superforms/adapters'
import { superValidate } from 'sveltekit-superforms/server' import { superValidate } from 'sveltekit-superforms/server'
import { collection_items, collections, gamesTable } from '../../../../../lib/server/api/databases/tables'
export async function load(event) { export async function load(event) {
const { params, locals } = event const { params, locals } = event

View file

@ -3,10 +3,10 @@
import Game from '$components/Game.svelte' import Game from '$components/Game.svelte'
import type { UICollection } from '$lib/types' import type { UICollection } from '$lib/types'
export let data const { data } = $props()
const { items = [] } = data
console.log(`Page data: ${JSON.stringify(data)}`) console.log(`Page data: ${JSON.stringify(data)}`)
let collection: UICollection = data?.collection ?? {} let collection: UICollection = data?.collection ?? {}
let items = data?.items || []
console.log('items', items) console.log('items', items)
// async function handleNextPageEvent(event: CustomEvent) { // async function handleNextPageEvent(event: CustomEvent) {
@ -40,7 +40,7 @@ console.log('items', items)
<div class="games"> <div class="games">
<div class="games-list"> <div class="games-list">
{#if items.length === 0} {#if items.length === 0}
<h2>No gamesTable in your collection</h2> <h2>No games in your collection</h2>
{:else} {:else}
{#each items as game (game.game_id)} {#each items as game (game.game_id)}
<Game {game} /> <Game {game} />

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

@ -18,14 +18,6 @@ export const load: PageServerLoad = async (event) => {
throw redirect(302, '/login', notSignedInMessage, event) throw redirect(302, '/login', notSignedInMessage, event)
} }
console.log('authedUser', authedUser)
// if (userNotAuthenticated(user, session)) {
// redirect(302, '/login', notSignedInMessage, event);
// }
// const dbUser = await db.query.usersTable.findFirst({
// where: eq(usersTable.id, user!.id!),
// });
const profileForm = await superValidate(zod(updateProfileSchema), { const profileForm = await superValidate(zod(updateProfileSchema), {
defaults: { defaults: {
firstName: authedUser?.firstName ?? '', firstName: authedUser?.firstName ?? '',

View file

@ -48,53 +48,26 @@ export const actions: Actions = {
}) })
} }
console.log('updating profile') const { error: verifyPasswordError } = 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) { console.log('verifyPasswordError', verifyPasswordError)
return fail(401)
}
const dbUser = await db.query.usersTable.findFirst({ if (verifyPasswordError) {
where: eq(usersTable.id, authedUser.id), console.error(verifyPasswordError)
})
// 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) {
return setError(form, 'current_password', 'Your password is incorrect') return setError(form, 'current_password', 'Your password is incorrect')
} }
if (authedUser?.username) { if (authedUser?.username) {
let sessionCookie: Cookie
try { try {
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.update.password.$put({
await lucia.invalidateUserSessions(authedUser.id) json: { password: form.data.password, confirm_password: form.data.confirm_password },
// 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()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
form.data.password = '' form.data.password = ''
@ -102,11 +75,6 @@ export const actions: Actions = {
form.data.current_password = '' form.data.current_password = ''
return setError(form, 'current_password', 'Your password is incorrect.') return setError(form, 'current_password', 'Your password is incorrect.')
} }
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes,
})
const message = { const message = {
type: 'success', type: 'success',
message: 'Password Updated. Please sign in.', message: 'Password Updated. Please sign in.',
@ -114,10 +82,5 @@ export const actions: Actions = {
redirect(302, '/login', message, event) redirect(302, '/login', message, event)
} }
return setError(form, 'Error occurred. Please try again or contact support if you need further help.') return setError(form, 'Error occurred. Please try again or contact support if you need further help.')
// TODO: Add toast instead?
// form.data.password = '';
// form.data.confirm_password = '';
// form.data.current_password = '';
// return message(form, 'Profile updated successfully.');
}, },
} }

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,37 +17,85 @@ const form = superForm(data.form, {
multipleSubmits: 'prevent', multipleSubmits: 'prevent',
}) })
let hiddenCurrentPassword = $state(true)
let hiddenPassword = $state(true)
let hiddenConfirmPassword = $state(true)
let currentPasswordInput = $derived(hiddenCurrentPassword ? 'password' : 'text')
let passwordInput = $derived(hiddenPassword ? 'password' : 'text')
let confirmPasswordInput = $derived(hiddenConfirmPassword ? 'password' : 'text')
// $inspect(hiddenCurrentPassword, hiddenPassword, hiddenConfirmPassword)
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"> <label for="username" class="sr-only">Username</label>
<input type="text" id="username" name="username" aria-hidden="true" class="hidden" autocomplete="username">
<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>Changing your password will log you out of all devices.</Alert.Description>
Changing your password will log you out of all devices.
</Alert.Description>
</Alert.Root> </Alert.Root>
<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={currentPasswordInput}
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={passwordInput}
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={confirmPasswordInput}
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>
@ -57,4 +106,4 @@ const { form: formData, enhance } = form
form { form {
max-width: 20rem; max-width: 20rem;
} }
</style> </style>

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

@ -102,7 +102,7 @@ export const actions: Actions = {
const { error: verifyPasswordError } = await locals.api.me.verify.password const { error: verifyPasswordError } = await locals.api.me.verify.password
.$post({ .$post({
json: { password: addTwoFactorForm.data.current_password }, json: { password: addTwoFactorForm.data.password },
}) })
.then(locals.parseApiResponse) .then(locals.parseApiResponse)
@ -144,7 +144,7 @@ export const actions: Actions = {
} }
const { error: verifyPasswordError } = await locals.api.me.verify.password const { error: verifyPasswordError } = await locals.api.me.verify.password
.$post({ .$post({
json: { password: removeTwoFactorForm.data.current_password }, json: { password: removeTwoFactorForm.data.password },
}) })
.then(locals.parseApiResponse) .then(locals.parseApiResponse)

View file

@ -42,7 +42,7 @@ const { form: removeTwoFactorFormData, enhance: removeTwoFactorEnhance } = remov
<Form.Field form={removeTwoFactorForm} name="current_password"> <Form.Field form={removeTwoFactorForm} name="current_password">
<Form.Control let:attrs> <Form.Control let:attrs>
<Form.Label for="password">Current Password</Form.Label> <Form.Label for="password">Current Password</Form.Label>
<Input type="password" {...attrs} bind:value={$removeTwoFactorFormData.current_password} /> <Input type="password" {...attrs} bind:value={$removeTwoFactorFormData.password} autocomplete="password" />
</Form.Control> </Form.Control>
<Form.Description>Please enter your current password.</Form.Description> <Form.Description>Please enter your current password.</Form.Description>
<Form.FieldErrors /> <Form.FieldErrors />
@ -64,7 +64,7 @@ const { form: removeTwoFactorFormData, enhance: removeTwoFactorEnhance } = remov
<Form.Field form={addTwoFactorForm} name="current_password"> <Form.Field form={addTwoFactorForm} name="current_password">
<Form.Control let:attrs> <Form.Control let:attrs>
<Form.Label for="password">Enter Password</Form.Label> <Form.Label for="password">Enter Password</Form.Label>
<Input type="password" {...attrs} bind:value={$addTwoFactorFormData.current_password} /> <Input type="password" {...attrs} bind:value={$addTwoFactorFormData.password} autocomplete="password" />
</Form.Control> </Form.Control>
<Form.Description>Please enter your current password.</Form.Description> <Form.Description>Please enter your current password.</Form.Description>
<Form.FieldErrors /> <Form.FieldErrors />

View file

@ -1,14 +1,14 @@
import { z } from 'zod' import { z } from 'zod'
export const addTwoFactorSchema = z.object({ export const addTwoFactorSchema = z.object({
current_password: z.string({ required_error: 'Current Password is required' }), password: z.string({ required_error: 'Current Password is required' }),
two_factor_code: z.string({ required_error: 'Two Factor Code is required' }).trim(), two_factor_code: z.string({ required_error: 'Two Factor Code is required' }).trim(),
}) })
export type AddTwoFactorSchema = typeof addTwoFactorSchema export type AddTwoFactorSchema = typeof addTwoFactorSchema
export const removeTwoFactorSchema = addTwoFactorSchema.pick({ export const removeTwoFactorSchema = addTwoFactorSchema.pick({
current_password: true, password: true,
}) })
export type RemoveTwoFactorSchema = typeof removeTwoFactorSchema export type RemoveTwoFactorSchema = typeof removeTwoFactorSchema

View file

@ -1,26 +1,28 @@
<script lang="ts"> <script lang="ts">
export let data const { data } = $props()
let wishlistsTable = data?.wishlists || [] const { wishlists = [] } = data
</script> </script>
<svelte:head> <svelte:head>
<title>Your Wishlists | Bored Game</title> <title>Your Wishlists | Bored Game</title>
</svelte:head> </svelte:head>
<h1>Your wishlistsTable</h1> <div class="container">
<h1>Your wishlistsTable</h1>
<div class="wishlists"> <div class="wishlists">
<div class="wishlist-list"> <div class="wishlist-list">
{#if wishlists.length === 0} {#if wishlists.length === 0}
<h2>You have no wishlistsTable</h2> <h2>You have no wishlistsTable</h2>
{:else} {:else}
{#each wishlists as wishlist} {#each wishlists as wishlist}
<div class="collection grid gap-0.5"> <div class="collection grid gap-0.5">
<h2><a href="/wishlists/{wishlist.cuid}">{wishlist.name}</a></h2> <h2><a href="/wishlists/{wishlist.cuid}">{wishlist.name}</a></h2>
<h3>Created at: {new Date(wishlist.created_at).toLocaleString()}</h3> <h3>Created at: {new Date(wishlist.created_at).toLocaleString()}</h3>
</div> </div>
{/each} {/each}
{/if} {/if}
</div>
</div> </div>
</div> </div>

View file

@ -1,35 +1,44 @@
<script lang="ts"> <script lang="ts">
import Game from '$components/Game.svelte' import Game from '$components/Game.svelte'
export let data const { data } = $props()
console.log('data', data) const { items = [] } = data
const items = data.items || []
</script> </script>
<svelte:head> <svelte:head>
<title>{`Your Wishlist | Bored Game`}</title> <title>{`Your Wishlist | Bored Game`}</title>
</svelte:head> </svelte:head>
<h1>Your wishlist</h1> <div class="container">
<h1>Your wishlist</h1>
<div class="games-list"> <div class="games-list">
{#if items.length > 0} {#if items.length > 0}
{#each items as item (item.id)} {#each items as item (item.id)}
<Game game={item.game} /> <Game game={item.game} />
{/each} {/each}
{:else} {:else}
<h2>Sorry no gamesTable found!</h2> <h2>Sorry no games found!</h2>
{/if} {/if}
</div>
</div> </div>
<style lang="postcss"> <style lang="postcss">
h1 { h1 {
margin: 1.5rem 0rem; margin: 1.5rem 0;
width: 100%; }
.container {
display: grid;
place-content: center;
max-width: 900px;
gap: 0.25rem;
margin-left: auto;
margin-right: auto;
} }
.games { .games {
margin: 2rem 0rem; margin: 2rem 0;
} }
.games-list { .games-list {

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

@ -17,8 +17,6 @@ const welcomeName = $derived.by(() => {
return welcomeName return welcomeName
}) })
$inspect(data)
</script> </script>
<div class="container"> <div class="container">
@ -53,5 +51,7 @@ $inspect(data)
place-content: center; place-content: center;
max-width: 900px; max-width: 900px;
gap: 0.25rem; gap: 0.25rem;
margin-left: auto;
margin-right: auto;
} }
</style> </style>

View file

@ -1,5 +1,5 @@
<h1>Privacy Policy</h1> <h1>Privacy Policy</h1>
<h2>Last Updated: September 13th, 2023</h2> <h2>Last Updated: September 19th, 2024</h2>
At Bored Game, we respect your privacy and are committed to protecting your personal information. At Bored Game, we respect your privacy and are committed to protecting your personal information.
We collect only the personal information that is necessary for us to provide our services to you. We collect only the personal information that is necessary for us to provide our services to you.

View file

@ -0,0 +1,26 @@
import { StatusCodes } from '$lib/constants/status-codes'
import type { RequestEvent } from '@sveltejs/kit'
import { redirect } from 'sveltekit-flash-message/server'
export async function GET(event: RequestEvent): Promise<Response> {
const { locals, url } = event
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
console.log('code', code, 'state', state)
const { data, error } = await locals.api.oauth.github.$get({ query: { code, state } }).then(locals.parseApiResponse)
if (error) {
return new Response(JSON.stringify(error), {
status: 400,
})
}
if (!data) {
return new Response(JSON.stringify({ message: 'Invalid request' }), {
status: 400,
})
}
redirect(StatusCodes.TEMPORARY_REDIRECT, '/')
}

View file

@ -0,0 +1,26 @@
import { StatusCodes } from '$lib/constants/status-codes'
import type { RequestEvent } from '@sveltejs/kit'
import { redirect } from 'sveltekit-flash-message/server'
export async function GET(event: RequestEvent): Promise<Response> {
const { locals, url } = event
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
console.log('code', code, 'state', state)
const { data, error } = await locals.api.oauth.google.$get({ query: { code, state } }).then(locals.parseApiResponse)
if (error) {
return new Response(JSON.stringify(error), {
status: 400,
})
}
if (!data) {
return new Response(JSON.stringify({ message: 'Invalid request' }), {
status: 400,
})
}
redirect(StatusCodes.TEMPORARY_REDIRECT, '/')
}

View file

@ -1,3 +1,4 @@
import { StatusCodes } from '$lib/constants/status-codes'
import { signinUsernameDto } from '$lib/dtos/signin-username.dto' import { signinUsernameDto } from '$lib/dtos/signin-username.dto'
import { type Actions, fail } from '@sveltejs/kit' import { type Actions, fail } from '@sveltejs/kit'
import { redirect } from 'sveltekit-flash-message/server' import { redirect } from 'sveltekit-flash-message/server'
@ -136,6 +137,8 @@ export const actions: Actions = {
form.data.username = '' form.data.username = ''
form.data.password = '' form.data.password = ''
redirect(StatusCodes.TEMPORARY_REDIRECT, '/')
// if ( // if (
// twoFactorDetails?.enabled && // twoFactorDetails?.enabled &&
// twoFactorDetails?.secret !== null && // twoFactorDetails?.secret !== null &&

View file

@ -1,41 +1,41 @@
<script lang="ts"> <script lang="ts">
import { zodClient } from 'sveltekit-superforms/adapters'; import * as Alert from '$components/ui/alert'
import { superForm } from 'sveltekit-superforms/client'; import { Button } from '$components/ui/button'
import * as flashModule from 'sveltekit-flash-message/client'; import { Input } from '$components/ui/input'
import { AlertCircle } from "lucide-svelte"; import { Label } from '$components/ui/label'
import { signInSchema } from '$lib/validations/auth'; import * as Card from '$lib/components/ui/card'
import * as Card from '$lib/components/ui/card'; import * as Form from '$lib/components/ui/form'
import * as Form from '$lib/components/ui/form'; import { boredState } from '$lib/stores/boredState.js'
import { Label } from '$components/ui/label'; import { receive, send } from '$lib/utils/pageCrossfade'
import { Input } from '$components/ui/input'; import { signInSchema } from '$lib/validations/auth'
import { Button } from '$components/ui/button'; import { AlertCircle } from 'lucide-svelte'
import * as Alert from "$components/ui/alert"; import * as flashModule from 'sveltekit-flash-message/client'
import { send, receive } from '$lib/utils/pageCrossfade'; import { zodClient } from 'sveltekit-superforms/adapters'
import { boredState } from '$lib/stores/boredState.js'; import { superForm } from 'sveltekit-superforms/client'
let { data } = $props(); let { data } = $props()
const superLoginForm = superForm(data.form, { const superLoginForm = superForm(data.form, {
onSubmit: () => boredState.update((n) => ({ ...n, loading: true })), onSubmit: () => boredState.update((n) => ({ ...n, loading: true })),
onResult: () => boredState.update((n) => ({ ...n, loading: false })), onResult: () => boredState.update((n) => ({ ...n, loading: false })),
flashMessage: { flashMessage: {
module: flashModule, module: flashModule,
onError: ({ result, flashMessage }) => { onError: ({ result, flashMessage }) => {
// Error handling for the flash message: // Error handling for the flash message:
// - result is the ActionResult // - result is the ActionResult
// - message is the flash store (not the status message store) // - message is the flash store (not the status message store)
const errorMessage = result.error.message const errorMessage = result.error.message
flashMessage.set({ type: 'error', message: errorMessage }); flashMessage.set({ type: 'error', message: errorMessage })
}
}, },
syncFlashMessage: false, },
taintedMessage: null, syncFlashMessage: false,
// validators: zodClient(signInSchema), taintedMessage: null,
// validationMethod: 'oninput', // validators: zodClient(signInSchema),
delayMs: 0, // validationMethod: 'oninput',
}); delayMs: 0,
})
const { form: loginForm, enhance } = superLoginForm; const { form: loginForm, enhance } = superLoginForm
</script> </script>
<svelte:head> <svelte:head>
@ -47,8 +47,10 @@
<Card.Header> <Card.Header>
<Card.Title class="text-2xl">Log into your account</Card.Title> <Card.Title class="text-2xl">Log into your account</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content class="grid gap-4">
{@render usernamePasswordForm()} {@render usernamePasswordForm()}
<span class="text-center text-sm text-muted-foreground">or sign in with</span>
{@render oAuthButtons()}
<p class="px-8 py-4 text-center text-sm text-muted-foreground"> <p class="px-8 py-4 text-center text-sm text-muted-foreground">
By clicking continue, you agree to our By clicking continue, you agree to our
<a href="/terms" class="underline underline-offset-4 hover:text-primary"> <a href="/terms" class="underline underline-offset-4 hover:text-primary">
@ -86,5 +88,17 @@
</form> </form>
{/snippet} {/snippet}
{#snippet oAuthButtons()}
<div class="grid gap-4">
<Button href="/login/google" variant="outline" class="w-full flex items-center gap-2"><svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/></svg> Google</Button>
<Button href="/login/apple" variant="outline" class="w-full flex items-center gap-2"><svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Apple</title><path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"/></svg> Apple</Button>
<Button href="/login/github" variant="outline" class="w-full flex items-center gap-2"><svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg> GitHub</Button>
</div>
{/snippet}
<style lang="postcss"> <style lang="postcss">
svg {
width: 24px;
height: 24px;
}
</style> </style>

View file

@ -0,0 +1,20 @@
import { github } from '$lib/server/auth'
import { redirect } from '@sveltejs/kit'
import { generateState } from 'arctic'
import type { RequestEvent } from '@sveltejs/kit'
export async function GET(event: RequestEvent): Promise<Response> {
const state = generateState()
const url = await github.createAuthorizationURL(state)
event.cookies.set('github_oauth_state', state, {
path: '/',
secure: import.meta.env.PROD,
httpOnly: true,
maxAge: 60 * 10,
sameSite: 'lax',
})
redirect(302, url.toString())
}

View file

@ -0,0 +1,20 @@
import { github } from '$lib/server/auth'
import { redirect } from '@sveltejs/kit'
import { generateState } from 'arctic'
import type { RequestEvent } from '@sveltejs/kit'
export async function GET(event: RequestEvent): Promise<Response> {
const state = generateState()
const url = await github.createAuthorizationURL(state)
event.cookies.set('github_oauth_state', state, {
path: '/',
secure: import.meta.env.PROD,
httpOnly: true,
maxAge: 60 * 10,
sameSite: 'lax',
})
redirect(302, url.toString())
}

View file

@ -0,0 +1,33 @@
import { google } from '$lib/server/auth'
import { redirect } from '@sveltejs/kit'
import { generateCodeVerifier, generateState } from 'arctic'
import type { RequestEvent } from '@sveltejs/kit'
// Google Login
export async function GET(event: RequestEvent): Promise<Response> {
const state = generateState()
const codeVerifier = generateCodeVerifier();
const url = await google.createAuthorizationURL(state, codeVerifier, {
scopes: ["profile", "email", "openid"]
})
event.cookies.set('google_oauth_state', state, {
path: '/',
secure: import.meta.env.PROD,
httpOnly: true,
maxAge: 60 * 10,
sameSite: 'lax',
})
event.cookies.set('google_oauth_code_verifier', codeVerifier, {
path: '/',
secure: import.meta.env.PROD,
httpOnly: true,
maxAge: 60 * 10,
sameSite: 'lax',
})
redirect(302, url.toString())
}

View file

@ -0,0 +1,20 @@
import { github } from '$lib/server/auth'
import { redirect } from '@sveltejs/kit'
import { generateState } from 'arctic'
import type { RequestEvent } from '@sveltejs/kit'
export async function GET(event: RequestEvent): Promise<Response> {
const state = generateState()
const url = await github.createAuthorizationURL(state)
event.cookies.set('github_oauth_state', state, {
path: '/',
secure: import.meta.env.PROD,
httpOnly: true,
maxAge: 60 * 10,
sameSite: 'lax',
})
redirect(302, url.toString())
}

View file

@ -0,0 +1,20 @@
import { github } from '$lib/server/auth'
import { redirect } from '@sveltejs/kit'
import { generateState } from 'arctic'
import type { RequestEvent } from '@sveltejs/kit'
export async function GET(event: RequestEvent): Promise<Response> {
const state = generateState()
const url = await github.createAuthorizationURL(state)
event.cookies.set('github_oauth_state', state, {
path: '/',
secure: import.meta.env.PROD,
httpOnly: true,
maxAge: 60 * 10,
sameSite: 'lax',
})
redirect(302, url.toString())
}

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>

View file

@ -16,7 +16,8 @@ export default defineConfig({
sveltekit() sveltekit()
], ],
test: { test: {
include: ['src/**/*.{test,spec}.{js,ts}'] include: ['src/**/*.{test,spec}.{js,ts}'],
mockReset: true,
}, },
css: { css: {
devSourcemap: true, devSourcemap: true,