init
This commit is contained in:
commit
6f5313f990
64 changed files with 7928 additions and 0 deletions
14
.blueprints.json
Normal file
14
.blueprints.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"source": "/Users/jd/code/base-code/nuxt-blueprints",
|
||||||
|
"generated": "2026-04-21",
|
||||||
|
"blocks": {
|
||||||
|
"core": {},
|
||||||
|
"email/mailgun": {},
|
||||||
|
"activity-log": {},
|
||||||
|
"rate-limiting/db": {},
|
||||||
|
"auth-jwt": {},
|
||||||
|
"admin": {},
|
||||||
|
"user-management": {},
|
||||||
|
"s3-storage": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
.editorconfig
Executable file
13
.editorconfig
Executable file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
19
.env.example
Normal file
19
.env.example
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
APP_TITLE=My App
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
||||||
|
NODE_ENV=development
|
||||||
|
NUXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-this
|
||||||
|
|
||||||
|
MAILGUN_API_KEY=your-mailgun-api-key
|
||||||
|
MAILGUN_DOMAIN=mg.yourdomain.com
|
||||||
|
# Optional: Use 'api.eu.mailgun.net' for EU region
|
||||||
|
MAILGUN_HOST=
|
||||||
|
SMTP_FROM=noreply@yourdomain.com
|
||||||
|
SMTP_FROM_NAME=My App
|
||||||
|
|
||||||
|
S3_ENDPOINT=https://s3.us-west-004.backblazeb2.com
|
||||||
|
S3_REGION=us-west-004
|
||||||
|
S3_ACCESS_KEY_ID=your-access-key-id
|
||||||
|
S3_SECRET_ACCESS_KEY=your-secret-access-key
|
||||||
|
S3_BUCKET_NAME=your-bucket-name
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
8
app/app.config.ts
Normal file
8
app/app.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
colors: {
|
||||||
|
primary: 'green',
|
||||||
|
neutral: 'slate'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
7
app/app.vue
Normal file
7
app/app.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<template>
|
||||||
|
<UApp>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</UApp>
|
||||||
|
</template>
|
||||||
25
app/assets/css/main.css
Normal file
25
app/assets/css/main.css
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "@nuxt/ui";
|
||||||
|
|
||||||
|
@theme static {
|
||||||
|
--font-sans: 'Public Sans', sans-serif;
|
||||||
|
|
||||||
|
--color-green-50: #EFFDF5;
|
||||||
|
--color-green-100: #D9FBE8;
|
||||||
|
--color-green-200: #B3F5D1;
|
||||||
|
--color-green-300: #75EDAE;
|
||||||
|
--color-green-400: #00DC82;
|
||||||
|
--color-green-500: #00C16A;
|
||||||
|
--color-green-600: #00A155;
|
||||||
|
--color-green-700: #007F45;
|
||||||
|
--color-green-800: #016538;
|
||||||
|
--color-green-900: #0A5331;
|
||||||
|
--color-green-950: #052E16;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
button:not(:disabled),
|
||||||
|
[role="button"]:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/components/ThemeToggle.vue
Normal file
17
app/components/ThemeToggle.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
size="lg"
|
||||||
|
@click="toggleTheme"
|
||||||
|
:aria-label="theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'"
|
||||||
|
title="Toggle theme"
|
||||||
|
>
|
||||||
|
<UIcon v-if="theme === 'light'" name="i-lucide-moon" class="size-5" />
|
||||||
|
<UIcon v-else name="i-lucide-sun" class="size-5" />
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
157
app/composables/useAuth.ts
Normal file
157
app/composables/useAuth.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
const CACHE_KEY = 'auth.user.cache'
|
||||||
|
|
||||||
|
const saveToCache = (userData: any) => {
|
||||||
|
if (import.meta.client && userData) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(CACHE_KEY, JSON.stringify(userData))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromCache = (): any => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
try {
|
||||||
|
const cached = sessionStorage.getItem(CACHE_KEY)
|
||||||
|
return cached ? JSON.parse(cached) : null
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearCache = () => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(CACHE_KEY)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const user = useState('auth.user', () => null as any)
|
||||||
|
const authReady = useState('auth.ready', () => false)
|
||||||
|
|
||||||
|
const restoreFromCache = () => {
|
||||||
|
const cached = loadFromCache()
|
||||||
|
if (cached) {
|
||||||
|
user.value = cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password }
|
||||||
|
}) as { success: boolean; user?: any }
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
user.value = response.user
|
||||||
|
saveToCache(response.user)
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const redirectTo = route.query.redirect as string
|
||||||
|
|
||||||
|
if (redirectTo && redirectTo.startsWith('/')) {
|
||||||
|
await navigateTo(redirectTo)
|
||||||
|
} else {
|
||||||
|
await navigateTo('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, message: 'Login failed' }
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.data?.message || 'An error occurred during login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||||
|
user.value = null
|
||||||
|
clearCache()
|
||||||
|
await navigateTo('/login')
|
||||||
|
} catch {
|
||||||
|
user.value = null
|
||||||
|
clearCache()
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const register = async (email: string, password: string, display_name: string) => {
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password, display_name }
|
||||||
|
}) as { success: boolean; message?: string; requiresVerification?: boolean; autoLoggedIn?: boolean; user?: any }
|
||||||
|
|
||||||
|
// First-ever user is promoted server-side and arrives already logged in.
|
||||||
|
if (response.autoLoggedIn && response.user) {
|
||||||
|
user.value = response.user
|
||||||
|
saveToCache(response.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.data?.statusMessage || error.data?.message || 'Registration failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/auth/me') as { user: any }
|
||||||
|
user.value = response.user
|
||||||
|
saveToCache(response.user)
|
||||||
|
return response.user
|
||||||
|
} catch {
|
||||||
|
user.value = null
|
||||||
|
clearCache()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!user.value)
|
||||||
|
|
||||||
|
const deleteAccount = async (password: string) => {
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/profile/account', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: { password }
|
||||||
|
}) as { success: boolean; message?: string }
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
user.value = null
|
||||||
|
clearCache()
|
||||||
|
await navigateTo('/login')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, message: 'Account deletion failed' }
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.data?.statusMessage || 'An error occurred while deleting your account'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: readonly(user),
|
||||||
|
authReady: readonly(authReady),
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
register,
|
||||||
|
checkAuth,
|
||||||
|
restoreFromCache,
|
||||||
|
isLoggedIn,
|
||||||
|
deleteAccount,
|
||||||
|
setAuthReady: (value: boolean) => { authReady.value = value }
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/composables/usePermissions.ts
Normal file
20
app/composables/usePermissions.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { Permission } from '~~/app/utils/permissions'
|
||||||
|
|
||||||
|
export const usePermissions = () => {
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
const roles = computed<string[]>(() => user.value?.roles ?? [])
|
||||||
|
const permissions = computed<string[]>(() => user.value?.permissions ?? [])
|
||||||
|
|
||||||
|
const hasRole = (name: string) => roles.value.includes(name)
|
||||||
|
const hasPermission = (name: Permission | string) => permissions.value.includes(name)
|
||||||
|
const isAdmin = computed(() => hasRole('admin'))
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles,
|
||||||
|
permissions,
|
||||||
|
hasRole,
|
||||||
|
hasPermission,
|
||||||
|
isAdmin
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/composables/useTheme.ts
Normal file
19
app/composables/useTheme.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
export const useTheme = () => {
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
|
const theme = computed(() => colorMode.value)
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
const initTheme = () => {
|
||||||
|
// No-op: Nuxt UI handles initialization automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
toggleTheme,
|
||||||
|
initTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/layouts/admin.vue
Normal file
129
app/layouts/admin.vue
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { hasPermission } = usePermissions()
|
||||||
|
const route = useRoute()
|
||||||
|
const mobileOpen = ref(false)
|
||||||
|
|
||||||
|
const navItems = computed(() => [
|
||||||
|
{ to: '/admin', label: 'Dashboard', icon: 'i-lucide-layout-dashboard' },
|
||||||
|
...(hasPermission('users.view')
|
||||||
|
? [{ to: '/admin/users', label: 'Users', icon: 'i-lucide-users' }]
|
||||||
|
: []),
|
||||||
|
...(hasPermission('roles.view')
|
||||||
|
? [{ to: '/admin/roles', label: 'Roles', icon: 'i-lucide-shield' }]
|
||||||
|
: [])
|
||||||
|
])
|
||||||
|
|
||||||
|
const isActive = (to: string) => {
|
||||||
|
if (to === '/admin') return route.path === '/admin'
|
||||||
|
return route.path === to || route.path.startsWith(to + '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
mobileOpen.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-(--ui-bg) text-(--ui-text)">
|
||||||
|
<!-- Mobile top bar -->
|
||||||
|
<header class="lg:hidden bg-(--ui-bg-elevated) border-b border-(--ui-border) py-3 px-4 sticky top-0 z-40 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-menu"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
aria-label="Open menu"
|
||||||
|
@click="mobileOpen = true"
|
||||||
|
/>
|
||||||
|
<span class="text-lg font-semibold">Admin</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-(--ui-text-muted) truncate max-w-[50%]">
|
||||||
|
{{ user?.display_name || user?.email }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="lg:flex">
|
||||||
|
<!-- Desktop sidebar -->
|
||||||
|
<aside class="hidden lg:flex lg:flex-col w-64 min-h-screen border-r border-(--ui-border) bg-(--ui-bg-elevated) sticky top-0 h-screen">
|
||||||
|
<div class="px-6 py-5 border-b border-(--ui-border)">
|
||||||
|
<h1 class="text-xl font-semibold">Admin</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors"
|
||||||
|
:class="isActive(item.to)
|
||||||
|
? 'bg-(--ui-bg-accented) text-(--ui-text) font-medium'
|
||||||
|
: 'text-(--ui-text-muted) hover:bg-(--ui-bg-accented) hover:text-(--ui-text)'"
|
||||||
|
>
|
||||||
|
<UIcon :name="item.icon" class="size-5 shrink-0" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
<div class="border-t border-(--ui-border) px-4 py-4 space-y-2">
|
||||||
|
<div class="text-sm text-(--ui-text-muted) truncate">
|
||||||
|
{{ user?.display_name || user?.email }}
|
||||||
|
</div>
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="flex items-center gap-2 text-sm text-(--ui-text-muted) hover:text-(--ui-text) transition-colors"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-arrow-left" class="size-4" />
|
||||||
|
<span>Back to app</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Mobile drawer -->
|
||||||
|
<USlideover v-model:open="mobileOpen" side="left" :ui="{ content: 'max-w-xs' }">
|
||||||
|
<template #content>
|
||||||
|
<div class="flex flex-col h-full bg-(--ui-bg-elevated)">
|
||||||
|
<div class="px-6 py-5 border-b border-(--ui-border) flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold">Admin</h1>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-x"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
aria-label="Close menu"
|
||||||
|
@click="mobileOpen = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors"
|
||||||
|
:class="isActive(item.to)
|
||||||
|
? 'bg-(--ui-bg-accented) text-(--ui-text) font-medium'
|
||||||
|
: 'text-(--ui-text-muted) hover:bg-(--ui-bg-accented) hover:text-(--ui-text)'"
|
||||||
|
>
|
||||||
|
<UIcon :name="item.icon" class="size-5 shrink-0" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
<div class="border-t border-(--ui-border) px-4 py-4 space-y-2">
|
||||||
|
<div class="text-sm text-(--ui-text-muted) truncate">
|
||||||
|
{{ user?.display_name || user?.email }}
|
||||||
|
</div>
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="flex items-center gap-2 text-sm text-(--ui-text-muted) hover:text-(--ui-text) transition-colors"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-arrow-left" class="size-4" />
|
||||||
|
<span>Back to app</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</USlideover>
|
||||||
|
|
||||||
|
<main class="flex-1 px-4 sm:px-6 lg:px-8 py-6 lg:py-8">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
12
app/layouts/auth.vue
Normal file
12
app/layouts/auth.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col bg-(--ui-bg)">
|
||||||
|
<div class="flex justify-end p-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex items-center justify-center px-4 pb-12">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
35
app/layouts/default.vue
Normal file
35
app/layouts/default.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { user } = useAuth()
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-(--ui-bg) text-(--ui-text)">
|
||||||
|
<header class="bg-(--ui-bg-elevated) border-b border-(--ui-border) py-4 sticky top-0 z-50">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 flex justify-between items-center">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<slot name="header-left">
|
||||||
|
<NuxtLink to="/" class="text-xl font-semibold hover:text-(--ui-text-muted) transition-colors">
|
||||||
|
{{ config.public.appName }}
|
||||||
|
</NuxtLink>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<slot name="header-right">
|
||||||
|
<span class="text-(--ui-text-muted) text-sm hidden sm:inline">
|
||||||
|
{{ user?.display_name || user?.email }}
|
||||||
|
</span>
|
||||||
|
<UButton to="/profile" variant="ghost" size="sm">
|
||||||
|
Profile
|
||||||
|
</UButton>
|
||||||
|
<ThemeToggle />
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
16
app/middleware/admin.ts
Normal file
16
app/middleware/admin.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export default defineNuxtRouteMiddleware(async (_to, _from) => {
|
||||||
|
const { user, checkAuth } = useAuth()
|
||||||
|
|
||||||
|
let currentUser = user.value
|
||||||
|
if (!currentUser) {
|
||||||
|
currentUser = await checkAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return navigateTo('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentUser.permissions?.includes('admin.access')) {
|
||||||
|
return navigateTo('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
12
app/middleware/auth.ts
Normal file
12
app/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||||
|
const { user, checkAuth } = useAuth()
|
||||||
|
|
||||||
|
if (!user.value) {
|
||||||
|
const authUser = await checkAuth()
|
||||||
|
|
||||||
|
if (!authUser) {
|
||||||
|
const redirectUrl = to.fullPath
|
||||||
|
return navigateTo(`/login?redirect=${encodeURIComponent(redirectUrl)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
12
app/pages/admin/index.vue
Normal file
12
app/pages/admin/index.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: ['auth', 'admin']
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
129
app/pages/admin/roles.vue
Normal file
129
app/pages/admin/roles.vue
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ROLES, type RoleDefinition } from '~~/app/utils/role-definitions'
|
||||||
|
import { PERMISSIONS, PERMISSION_META, type Permission } from '~~/app/utils/permissions'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: ['auth', 'admin']
|
||||||
|
})
|
||||||
|
|
||||||
|
const roles = computed<RoleDefinition[]>(() => Object.values(ROLES))
|
||||||
|
|
||||||
|
const roleIcons: Record<string, string> = {
|
||||||
|
admin: 'i-lucide-shield',
|
||||||
|
member: 'i-lucide-user'
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedRole = ref<string | null>(null)
|
||||||
|
const toggleExpandedRole = (name: string) => {
|
||||||
|
expandedRole.value = expandedRole.value === name ? null : name
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionGroup {
|
||||||
|
resource: string
|
||||||
|
permissions: { perm: Permission; granted: boolean; title: string; description: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPermissionGroups = (role: RoleDefinition): PermissionGroup[] => {
|
||||||
|
const groups = new Map<string, PermissionGroup['permissions']>()
|
||||||
|
for (const perm of PERMISSIONS) {
|
||||||
|
const resource = perm.includes('.') ? perm.split('.', 1)[0]! : 'general'
|
||||||
|
if (!groups.has(resource)) groups.set(resource, [])
|
||||||
|
const meta = PERMISSION_META[perm]
|
||||||
|
groups.get(resource)!.push({
|
||||||
|
perm,
|
||||||
|
granted: (role.permissions as readonly string[]).includes(perm),
|
||||||
|
title: meta?.title ?? perm,
|
||||||
|
description: meta?.description ?? ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([resource, permissions]) => ({ resource, permissions }))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Roles</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="role in roles"
|
||||||
|
:key="role.name"
|
||||||
|
class="rounded-lg border border-(--ui-border) bg-(--ui-bg-elevated) overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center justify-between p-4 hover:bg-(--ui-bg-accented)/50 transition-colors text-left"
|
||||||
|
:aria-expanded="expandedRole === role.name"
|
||||||
|
@click="toggleExpandedRole(role.name)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<UIcon
|
||||||
|
:name="roleIcons[role.name] || 'i-lucide-user'"
|
||||||
|
class="size-5 text-(--ui-text-muted) shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium capitalize">{{ role.name }}</p>
|
||||||
|
<p class="text-xs text-(--ui-text-muted) truncate">{{ role.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<UBadge color="neutral" variant="outline" size="sm">
|
||||||
|
{{ role.permissions.length }} / {{ PERMISSIONS.length }} permissions
|
||||||
|
</UBadge>
|
||||||
|
<UIcon
|
||||||
|
:name="expandedRole === role.name ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||||
|
class="size-4 text-(--ui-text-muted)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="expandedRole === role.name"
|
||||||
|
class="border-t border-(--ui-border) p-4 space-y-5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="group in getPermissionGroups(role)"
|
||||||
|
:key="group.resource"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium text-(--ui-text-muted) uppercase tracking-wider mb-2">
|
||||||
|
{{ group.resource }}
|
||||||
|
</p>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="p in group.permissions"
|
||||||
|
:key="p.perm"
|
||||||
|
class="flex items-start gap-2 py-1 px-2"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
:name="p.granted ? 'i-lucide-check' : 'i-lucide-x'"
|
||||||
|
:class="p.granted
|
||||||
|
? 'size-4 text-(--ui-success) shrink-0 mt-0.5'
|
||||||
|
: 'size-4 text-(--ui-text-muted)/50 shrink-0 mt-0.5'"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
class="text-sm"
|
||||||
|
:class="p.granted ? '' : 'text-(--ui-text-muted)'"
|
||||||
|
>
|
||||||
|
{{ p.title }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="p.description"
|
||||||
|
class="text-xs text-(--ui-text-muted) ml-2"
|
||||||
|
>
|
||||||
|
— {{ p.description }}
|
||||||
|
</span>
|
||||||
|
<code class="ml-2 text-xs text-(--ui-text-muted)/70 font-mono">{{ p.perm }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
663
app/pages/admin/users.vue
Normal file
663
app/pages/admin/users.vue
Normal file
|
|
@ -0,0 +1,663 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { h, resolveComponent } from 'vue'
|
||||||
|
import type { TableColumn } from '@nuxt/ui'
|
||||||
|
import { ROLES, type RoleDefinition } from '~~/app/utils/role-definitions'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: ['auth', 'admin']
|
||||||
|
})
|
||||||
|
|
||||||
|
interface AdminUserRow {
|
||||||
|
id: string
|
||||||
|
display_name: string
|
||||||
|
email: string
|
||||||
|
verified: boolean
|
||||||
|
created: string
|
||||||
|
last_login: string | null
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssignableRole {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
source: 'static' | 'custom'
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticRoles = Object.values(ROLES) as RoleDefinition[]
|
||||||
|
|
||||||
|
const availableRoles = computed<AssignableRole[]>(() =>
|
||||||
|
staticRoles.map(r => ({
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
source: 'static' as const,
|
||||||
|
permissions: [...r.permissions]
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const { permissions: myPermissions, hasPermission } = usePermissions()
|
||||||
|
|
||||||
|
const canManageUsers = computed(() => hasPermission('users.manage'))
|
||||||
|
|
||||||
|
const canAssignRole = (role: AssignableRole) => {
|
||||||
|
const mine = new Set(myPermissions.value)
|
||||||
|
return role.permissions.every(p => mine.has(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingPermsForRole = (role: AssignableRole) => {
|
||||||
|
const mine = new Set(myPermissions.value)
|
||||||
|
return role.permissions.filter(p => !mine.has(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsersResponse {
|
||||||
|
rows: AdminUserRow[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const UIcon = resolveComponent('UIcon')
|
||||||
|
const UButton = resolveComponent('UButton')
|
||||||
|
const UBadge = resolveComponent('UBadge')
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const { user: currentUser } = useAuth()
|
||||||
|
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(50)
|
||||||
|
const search = ref('')
|
||||||
|
const sortField = ref<'display_name' | 'email' | 'verified' | 'created' | 'last_login'>('created')
|
||||||
|
const sortDir = ref<'asc' | 'desc'>('desc')
|
||||||
|
|
||||||
|
const searchDebounced = ref('')
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
watch(search, (val) => {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
searchDebounced.value = val
|
||||||
|
page.value = 1
|
||||||
|
}, 250)
|
||||||
|
})
|
||||||
|
|
||||||
|
const queryKey = computed(() => ({
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
q: searchDebounced.value,
|
||||||
|
sort: sortField.value,
|
||||||
|
dir: sortDir.value
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { data, pending, error } = await useFetch<UsersResponse>('/api/admin/users', {
|
||||||
|
query: queryKey,
|
||||||
|
watch: [queryKey],
|
||||||
|
default: () => ({ rows: [], total: 0, page: 1, pageSize: 50 })
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleSort = (field: typeof sortField.value) => {
|
||||||
|
if (sortField.value === field) {
|
||||||
|
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
||||||
|
} else {
|
||||||
|
sortField.value = field
|
||||||
|
sortDir.value = 'asc'
|
||||||
|
}
|
||||||
|
page.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortIcon = (field: typeof sortField.value) => {
|
||||||
|
if (sortField.value !== field) return 'i-lucide-chevrons-up-down'
|
||||||
|
return sortDir.value === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (value: string | Date | null | undefined) => {
|
||||||
|
if (!value) return '—'
|
||||||
|
const d = typeof value === 'string' ? new Date(value) : value
|
||||||
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
|
return d.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTimestamp = (input: string | number | null | undefined) => {
|
||||||
|
if (!input) return '—'
|
||||||
|
const d = new Date(input)
|
||||||
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
|
return d.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeTime = (input: string | number | Date | null | undefined) => {
|
||||||
|
if (!input) return null
|
||||||
|
const d = input instanceof Date
|
||||||
|
? input
|
||||||
|
: new Date(typeof input === 'string' ? input : Number(input))
|
||||||
|
if (Number.isNaN(d.getTime())) return null
|
||||||
|
const diffMs = Date.now() - d.getTime()
|
||||||
|
const abs = Math.abs(diffMs)
|
||||||
|
const minute = 60_000
|
||||||
|
const hour = 60 * minute
|
||||||
|
const day = 24 * hour
|
||||||
|
const week = 7 * day
|
||||||
|
const month = 30 * day
|
||||||
|
const year = 365 * day
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||||
|
const sign = diffMs >= 0 ? -1 : 1
|
||||||
|
if (abs < minute) return rtf.format(sign * Math.round(abs / 1000), 'second')
|
||||||
|
if (abs < hour) return rtf.format(sign * Math.round(abs / minute), 'minute')
|
||||||
|
if (abs < day) return rtf.format(sign * Math.round(abs / hour), 'hour')
|
||||||
|
if (abs < week) return rtf.format(sign * Math.round(abs / day), 'day')
|
||||||
|
if (abs < month) return rtf.format(sign * Math.round(abs / week), 'week')
|
||||||
|
if (abs < year) return rtf.format(sign * Math.round(abs / month), 'month')
|
||||||
|
return rtf.format(sign * Math.round(abs / year), 'year')
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialsOf = (name: string | undefined | null, email: string | undefined | null) => {
|
||||||
|
const source = (name || email || '').trim()
|
||||||
|
if (!source) return '?'
|
||||||
|
const parts = source.split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase()
|
||||||
|
}
|
||||||
|
return source.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortableHeader = (label: string, field: typeof sortField.value) => {
|
||||||
|
return () => h(UButton, {
|
||||||
|
variant: 'ghost',
|
||||||
|
color: 'neutral',
|
||||||
|
size: 'xs',
|
||||||
|
class: '-mx-2',
|
||||||
|
trailingIcon: sortIcon(field),
|
||||||
|
onClick: (e: MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
toggleSort(field)
|
||||||
|
}
|
||||||
|
}, () => label)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumn<AdminUserRow>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'display_name',
|
||||||
|
header: sortableHeader('Name', 'display_name'),
|
||||||
|
cell: ({ row }) => row.original.display_name || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'email',
|
||||||
|
header: sortableHeader('Email', 'email')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'verified',
|
||||||
|
header: sortableHeader('Verified', 'verified'),
|
||||||
|
cell: ({ row }) => row.original.verified
|
||||||
|
? h(UIcon, { name: 'i-lucide-check', class: 'size-5 text-(--ui-success)' })
|
||||||
|
: h('span', { class: 'text-(--ui-text-muted)' }, '—')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'roles',
|
||||||
|
header: 'Roles',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const roles = row.original.roles ?? []
|
||||||
|
if (roles.length === 0) {
|
||||||
|
return h('span', { class: 'text-(--ui-text-muted) text-sm' }, '—')
|
||||||
|
}
|
||||||
|
return h('div', { class: 'flex flex-wrap gap-1' },
|
||||||
|
roles.map(r => h(UBadge, {
|
||||||
|
key: r,
|
||||||
|
color: r === 'admin' ? 'warning' : 'neutral',
|
||||||
|
variant: 'subtle',
|
||||||
|
size: 'sm'
|
||||||
|
}, () => r))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created',
|
||||||
|
header: sortableHeader('Created', 'created'),
|
||||||
|
cell: ({ row }) => formatDate(row.original.created)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'last_login',
|
||||||
|
header: sortableHeader('Last login', 'last_login'),
|
||||||
|
cell: ({ row }) => formatTimestamp(row.original.last_login)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const total = computed(() => data.value?.total ?? 0)
|
||||||
|
const rows = computed(() => data.value?.rows ?? [])
|
||||||
|
|
||||||
|
// Edit slideover state
|
||||||
|
const selectedUser = ref<AdminUserRow | null>(null)
|
||||||
|
const editName = ref('')
|
||||||
|
const editRoles = ref<string[]>([])
|
||||||
|
const saving = ref(false)
|
||||||
|
const savingRoles = ref(false)
|
||||||
|
|
||||||
|
const slideoverOpen = computed({
|
||||||
|
get: () => selectedUser.value !== null,
|
||||||
|
set: (val: boolean) => {
|
||||||
|
if (!val) selectedUser.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const trimmedName = computed(() => editName.value.trim())
|
||||||
|
const nameValid = computed(() => trimmedName.value.length >= 2)
|
||||||
|
const nameChanged = computed(() =>
|
||||||
|
selectedUser.value !== null && trimmedName.value !== selectedUser.value.display_name
|
||||||
|
)
|
||||||
|
const canSave = computed(() => nameValid.value && nameChanged.value && !saving.value)
|
||||||
|
|
||||||
|
const openRow = (row: AdminUserRow) => {
|
||||||
|
selectedUser.value = row
|
||||||
|
editName.value = row.display_name
|
||||||
|
editRoles.value = [...(row.roles ?? [])]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleRole = (name: string) => {
|
||||||
|
const idx = editRoles.value.indexOf(name)
|
||||||
|
if (idx === -1) {
|
||||||
|
editRoles.value = [...editRoles.value, name]
|
||||||
|
} else {
|
||||||
|
editRoles.value = editRoles.value.filter(r => r !== name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolesChanged = computed(() => {
|
||||||
|
if (!selectedUser.value) return false
|
||||||
|
const a = [...(selectedUser.value.roles ?? [])].sort()
|
||||||
|
const b = [...editRoles.value].sort()
|
||||||
|
return a.length !== b.length || a.some((r, i) => r !== b[i])
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSaveRoles = async () => {
|
||||||
|
if (!selectedUser.value || !rolesChanged.value) return
|
||||||
|
savingRoles.value = true
|
||||||
|
try {
|
||||||
|
const response = await $fetch<{ user: { id: string; roles: string[] } }>(
|
||||||
|
`/api/admin/users/${selectedUser.value.id}/roles`,
|
||||||
|
{ method: 'PUT', body: { roles: editRoles.value } }
|
||||||
|
)
|
||||||
|
|
||||||
|
const updatedRoles = response.user.roles
|
||||||
|
if (data.value) {
|
||||||
|
data.value = {
|
||||||
|
...data.value,
|
||||||
|
rows: data.value.rows.map(r =>
|
||||||
|
r.id === response.user.id ? { ...r, roles: updatedRoles } : r
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selectedUser.value) {
|
||||||
|
selectedUser.value = { ...selectedUser.value, roles: updatedRoles }
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({ title: 'Roles updated', color: 'success' })
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Update failed',
|
||||||
|
description: err?.data?.statusMessage || err?.message || 'Failed to update roles',
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
savingRoles.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRowSelect = (_event: Event, row: { original: AdminUserRow }) => {
|
||||||
|
if (!canManageUsers.value) return
|
||||||
|
openRow(row.original)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableMeta = computed(() => ({
|
||||||
|
class: {
|
||||||
|
tr: (row: { original: AdminUserRow }) =>
|
||||||
|
selectedUser.value?.id === row.original.id ? 'bg-(--ui-bg-accented)' : ''
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selectedUser.value || !canSave.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const response = await $fetch<{ user: AdminUserRow }>(`/api/admin/users/${selectedUser.value.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { display_name: trimmedName.value }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data.value) {
|
||||||
|
data.value = {
|
||||||
|
...data.value,
|
||||||
|
rows: data.value.rows.map(r => r.id === response.user.id ? response.user : r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({ title: 'User updated', color: 'success' })
|
||||||
|
selectedUser.value = null
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Update failed',
|
||||||
|
description: err?.data?.statusMessage || err?.message || 'Failed to update user',
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete flow
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
const isSelf = computed(() =>
|
||||||
|
!!(selectedUser.value && currentUser.value && selectedUser.value.id === currentUser.value.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const requestDelete = () => {
|
||||||
|
if (!selectedUser.value || isSelf.value) return
|
||||||
|
deleteModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedUser.value || isSelf.value) return
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
const userId = selectedUser.value.id
|
||||||
|
await $fetch(`/api/admin/users/${userId}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
if (data.value) {
|
||||||
|
data.value = {
|
||||||
|
...data.value,
|
||||||
|
rows: data.value.rows.filter(r => r.id !== userId),
|
||||||
|
total: Math.max(0, data.value.total - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({ title: 'User deleted', color: 'success' })
|
||||||
|
deleteModalOpen.value = false
|
||||||
|
selectedUser.value = null
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Delete failed',
|
||||||
|
description: err?.data?.statusMessage || err?.message || 'Failed to delete user',
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Users</h1>
|
||||||
|
<UInput
|
||||||
|
v-model="search"
|
||||||
|
placeholder="Search name or email..."
|
||||||
|
icon="i-lucide-search"
|
||||||
|
class="w-full sm:w-80"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert v-if="error" color="error" :title="error.statusMessage || 'Failed to load users'" class="mb-4" />
|
||||||
|
|
||||||
|
<div class="border border-(--ui-border) rounded-lg overflow-hidden bg-(--ui-bg-elevated)">
|
||||||
|
<UTable
|
||||||
|
:data="rows"
|
||||||
|
:columns="columns"
|
||||||
|
:loading="pending"
|
||||||
|
:empty-state="{ icon: 'i-lucide-users', label: 'No users found' }"
|
||||||
|
:on-select="handleRowSelect"
|
||||||
|
:meta="tableMeta"
|
||||||
|
:ui="{ tr: canManageUsers ? 'transition-colors cursor-pointer' : 'transition-colors' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 mt-4">
|
||||||
|
<p class="text-sm text-(--ui-text-muted)">
|
||||||
|
{{ total }} {{ total === 1 ? 'user' : 'users' }}
|
||||||
|
</p>
|
||||||
|
<UPagination
|
||||||
|
v-model:page="page"
|
||||||
|
:total="total"
|
||||||
|
:items-per-page="pageSize"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<USlideover
|
||||||
|
v-model:open="slideoverOpen"
|
||||||
|
side="right"
|
||||||
|
:title="selectedUser?.display_name || ''"
|
||||||
|
:ui="{ content: 'w-screen max-w-full sm:max-w-none sm:w-[50vw]' }"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UTooltip :text="isSelf ? 'Cannot delete your own account' : 'Delete user'">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="isSelf"
|
||||||
|
aria-label="Delete user"
|
||||||
|
@click="requestDelete"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div v-if="selectedUser" class="space-y-8">
|
||||||
|
<!-- Identity hero -->
|
||||||
|
<section class="flex items-start gap-4">
|
||||||
|
<div class="shrink-0 size-16 rounded-full bg-gradient-to-br from-(--ui-primary) to-(--ui-primary)/60 text-white flex items-center justify-center text-xl font-semibold ring-1 ring-(--ui-border) shadow-sm">
|
||||||
|
{{ initialsOf(selectedUser.display_name, selectedUser.email) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<h2 class="text-xl font-semibold truncate">{{ selectedUser.display_name }}</h2>
|
||||||
|
<UBadge
|
||||||
|
v-for="role in selectedUser.roles ?? []"
|
||||||
|
:key="role"
|
||||||
|
:color="role === 'admin' ? 'warning' : 'neutral'"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<UIcon v-if="role === 'admin'" name="i-lucide-shield" class="size-3 mr-1" />
|
||||||
|
{{ role }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
:href="`mailto:${selectedUser.email}`"
|
||||||
|
class="text-sm text-(--ui-text-muted) hover:text-(--ui-text) transition-colors inline-flex items-center gap-1.5 mt-0.5 truncate"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-mail" class="size-3.5 shrink-0" />
|
||||||
|
<span class="truncate">{{ selectedUser.email }}</span>
|
||||||
|
</a>
|
||||||
|
<div class="mt-2.5">
|
||||||
|
<UBadge
|
||||||
|
:color="selectedUser.verified ? 'success' : 'neutral'"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
:name="selectedUser.verified ? 'i-lucide-badge-check' : 'i-lucide-circle-dashed'"
|
||||||
|
class="size-3 mr-1"
|
||||||
|
/>
|
||||||
|
{{ selectedUser.verified ? 'Verified' : 'Unverified' }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stat cards -->
|
||||||
|
<section class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div class="rounded-lg border border-(--ui-border) bg-(--ui-bg-elevated) p-4">
|
||||||
|
<div class="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-(--ui-text-muted)">
|
||||||
|
<UIcon name="i-lucide-calendar-plus" class="size-3.5" />
|
||||||
|
<span>Created</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1.5 text-sm font-medium">{{ formatDate(selectedUser.created) }}</p>
|
||||||
|
<p class="text-xs text-(--ui-text-muted) mt-0.5">{{ relativeTime(selectedUser.created) || '—' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--ui-border) bg-(--ui-bg-elevated) p-4">
|
||||||
|
<div class="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-(--ui-text-muted)">
|
||||||
|
<UIcon name="i-lucide-log-in" class="size-3.5" />
|
||||||
|
<span>Last login</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1.5 text-sm font-medium">
|
||||||
|
{{ selectedUser.last_login ? formatTimestamp(selectedUser.last_login) : 'Never' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-(--ui-text-muted) mt-0.5">
|
||||||
|
{{ selectedUser.last_login ? relativeTime(selectedUser.last_login) : '—' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Edit form -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<UIcon name="i-lucide-pencil" class="size-4 text-(--ui-text-muted)" />
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-(--ui-text-muted)">Edit details</h3>
|
||||||
|
<div class="flex-1 h-px bg-(--ui-border)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="space-y-5" @submit.prevent="handleSave">
|
||||||
|
<UFormField
|
||||||
|
label="Display name"
|
||||||
|
:hint="`${trimmedName.length} / 2+ characters`"
|
||||||
|
:error="editName && !nameValid ? 'Must be at least 2 characters' : undefined"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="editName"
|
||||||
|
size="lg"
|
||||||
|
icon="i-lucide-user"
|
||||||
|
:disabled="saving"
|
||||||
|
placeholder="Display name"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 pt-2">
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
icon="i-lucide-save"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!canSave"
|
||||||
|
>
|
||||||
|
Save changes
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
size="lg"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="slideoverOpen = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Roles editor -->
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<UIcon name="i-lucide-shield" class="size-4 text-(--ui-text-muted)" />
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-(--ui-text-muted)">Roles</h3>
|
||||||
|
<div class="flex-1 h-px bg-(--ui-border)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label
|
||||||
|
v-for="role in availableRoles"
|
||||||
|
:key="role.name"
|
||||||
|
class="flex items-start gap-3 p-3 rounded-lg border border-(--ui-border) transition-colors"
|
||||||
|
:class="canAssignRole(role)
|
||||||
|
? 'hover:bg-(--ui-bg-accented) cursor-pointer'
|
||||||
|
: 'opacity-60 cursor-not-allowed'"
|
||||||
|
>
|
||||||
|
<UTooltip
|
||||||
|
:text="!canAssignRole(role)
|
||||||
|
? `You lack: ${missingPermsForRole(role).join(', ')}`
|
||||||
|
: ''"
|
||||||
|
:disabled="canAssignRole(role)"
|
||||||
|
>
|
||||||
|
<UCheckbox
|
||||||
|
:model-value="editRoles.includes(role.name)"
|
||||||
|
:disabled="savingRoles || !canAssignRole(role)"
|
||||||
|
@update:model-value="toggleRole(role.name)"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="font-medium">{{ role.name }}</span>
|
||||||
|
<UBadge
|
||||||
|
v-if="!canAssignRole(role)"
|
||||||
|
color="warning"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Cannot assign
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
<div v-if="role.description" class="text-sm text-(--ui-text-muted) mt-0.5">
|
||||||
|
{{ role.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 pt-4">
|
||||||
|
<UButton
|
||||||
|
size="lg"
|
||||||
|
icon="i-lucide-save"
|
||||||
|
:loading="savingRoles"
|
||||||
|
:disabled="!rolesChanged || savingRoles"
|
||||||
|
@click="handleSaveRoles"
|
||||||
|
>
|
||||||
|
Save roles
|
||||||
|
</UButton>
|
||||||
|
<p v-if="rolesChanged" class="text-sm text-(--ui-text-muted)">
|
||||||
|
{{ editRoles.length }} {{ editRoles.length === 1 ? 'role' : 'roles' }} selected
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</USlideover>
|
||||||
|
|
||||||
|
<UModal v-model:open="deleteModalOpen" :dismissible="!deleting">
|
||||||
|
<template #content>
|
||||||
|
<div class="p-6 space-y-5">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="shrink-0 size-10 rounded-full bg-(--ui-error)/10 flex items-center justify-center">
|
||||||
|
<UIcon name="i-lucide-triangle-alert" class="size-5 text-(--ui-error)" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-semibold">Delete user?</h3>
|
||||||
|
<p class="text-sm text-(--ui-text-muted) mt-1">
|
||||||
|
This will permanently delete
|
||||||
|
<span class="font-medium text-(--ui-text)">{{ selectedUser?.display_name }}</span>
|
||||||
|
({{ selectedUser?.email }}) and their password reset records. Activity history is retained. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-3 pt-2">
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="deleteModalOpen = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
color="error"
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
:loading="deleting"
|
||||||
|
@click="handleDelete"
|
||||||
|
>
|
||||||
|
Delete user
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
12
app/pages/dashboard.vue
Normal file
12
app/pages/dashboard.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Dashboard</h1>
|
||||||
|
<p class="text-(--ui-text-muted)">Welcome to your dashboard.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
34
app/pages/index.vue
Normal file
34
app/pages/index.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const { isLoggedIn } = useAuth()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-(--ui-bg)">
|
||||||
|
<div class="flex justify-end p-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<div class="text-center py-16 px-4">
|
||||||
|
<h1 class="text-4xl font-bold text-(--ui-text) mb-4">Welcome</h1>
|
||||||
|
<p class="text-(--ui-text-muted) mb-8 max-w-lg mx-auto">
|
||||||
|
Get started by customizing this page.
|
||||||
|
</p>
|
||||||
|
<div v-if="!isLoggedIn" class="flex items-center justify-center gap-3">
|
||||||
|
<UButton to="/login" size="lg">
|
||||||
|
Sign in
|
||||||
|
</UButton>
|
||||||
|
<UButton to="/register" size="lg" variant="outline">
|
||||||
|
Create an account
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<UButton to="/dashboard" size="lg">
|
||||||
|
Go to dashboard
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
292
app/pages/login.vue
Normal file
292
app/pages/login.vue
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const { login } = useAuth()
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const successMessage = ref('')
|
||||||
|
|
||||||
|
// Forgot password state
|
||||||
|
const view = ref<'login' | 'reset'>('login')
|
||||||
|
const resetEmail = ref('')
|
||||||
|
const resetLoading = ref(false)
|
||||||
|
const resetError = ref('')
|
||||||
|
const resetSuccess = ref(false)
|
||||||
|
const sentToEmail = ref('')
|
||||||
|
|
||||||
|
// Check for verification status in query params
|
||||||
|
onMounted(() => {
|
||||||
|
const verified = route.query.verified as string
|
||||||
|
if (verified === 'success') {
|
||||||
|
successMessage.value = 'Email verified successfully! You can now sign in.'
|
||||||
|
} else if (verified === 'already') {
|
||||||
|
successMessage.value = 'Your email is already verified. Please sign in.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await login(state.email, state.password)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
error.value = result.message || 'Login failed. Please check your credentials.'
|
||||||
|
}
|
||||||
|
// On success, useAuth composable will handle navigation
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || 'An error occurred during login. Please try again.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectToRegister = () => {
|
||||||
|
const redirect = route.query.redirect as string
|
||||||
|
if (redirect) {
|
||||||
|
router.push(`/register?redirect=${encodeURIComponent(redirect)}`)
|
||||||
|
} else {
|
||||||
|
router.push('/register')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleForgotPassword() {
|
||||||
|
resetError.value = ''
|
||||||
|
resetLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/auth/forgot-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
email: resetEmail.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
resetSuccess.value = true
|
||||||
|
sentToEmail.value = resetEmail.value
|
||||||
|
resetEmail.value = ''
|
||||||
|
} catch (err: any) {
|
||||||
|
resetError.value = err.data?.message || 'An error occurred. Please try again.'
|
||||||
|
} finally {
|
||||||
|
resetLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToLogin() {
|
||||||
|
view.value = 'login'
|
||||||
|
resetSuccess.value = false
|
||||||
|
resetError.value = ''
|
||||||
|
sentToEmail.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToReset() {
|
||||||
|
view.value = 'reset'
|
||||||
|
error.value = ''
|
||||||
|
successMessage.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Logo/Header -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-(--ui-text) mb-2">
|
||||||
|
{{ view === 'login' ? 'Welcome Back' : 'Reset Password' }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-(--ui-text-muted)">
|
||||||
|
{{ view === 'login' ? 'Sign in to your account' : 'Enter your email to receive reset instructions' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login View -->
|
||||||
|
<UCard v-if="view === 'login'" :ui="{ body: 'p-6 sm:p-8' }">
|
||||||
|
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||||
|
<!-- Success Alert -->
|
||||||
|
<UAlert
|
||||||
|
v-if="successMessage"
|
||||||
|
color="success"
|
||||||
|
variant="soft"
|
||||||
|
:title="successMessage"
|
||||||
|
@close="successMessage = ''"
|
||||||
|
:close-button="{ icon: 'i-lucide-x', color: 'gray', variant: 'ghost' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Error Alert -->
|
||||||
|
<UAlert
|
||||||
|
v-if="error"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
:title="error"
|
||||||
|
@close="error = ''"
|
||||||
|
:close-button="{ icon: 'i-lucide-x', color: 'gray', variant: 'ghost' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Email Input -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<UFormField label="Email" name="email" required>
|
||||||
|
<UInput
|
||||||
|
v-model="state.email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
size="lg"
|
||||||
|
:disabled="loading"
|
||||||
|
autocomplete="email"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Input -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<UFormField label="Password" name="password" required>
|
||||||
|
<UInput
|
||||||
|
v-model="state.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
size="lg"
|
||||||
|
:disabled="loading"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Forgot Password Link -->
|
||||||
|
<div class="text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="switchToReset"
|
||||||
|
class="text-sm text-(--ui-text-muted) hover:text-(--ui-text) transition-colors"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
Forgot Password?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="loading || !state.email || !state.password"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<div class="w-full border-t border-(--ui-border)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-sm">
|
||||||
|
<span class="px-2 bg-(--ui-bg) text-(--ui-text-muted)">Don't have an account?</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Register Link -->
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
@click="redirectToRegister"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</UButton>
|
||||||
|
</form>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Forgot Password View -->
|
||||||
|
<UCard v-else :ui="{ body: 'p-6 sm:p-8' }">
|
||||||
|
<form @submit.prevent="handleForgotPassword" class="space-y-6">
|
||||||
|
<!-- Success Message -->
|
||||||
|
<div v-if="resetSuccess" class="space-y-4">
|
||||||
|
<UAlert
|
||||||
|
color="success"
|
||||||
|
variant="soft"
|
||||||
|
title="Email Sent!"
|
||||||
|
:close-button="{ icon: 'i-lucide-x', color: 'gray', variant: 'ghost' }"
|
||||||
|
@close="resetSuccess = false"
|
||||||
|
/>
|
||||||
|
<p class="text-(--ui-text-muted)">
|
||||||
|
If an account with <strong>{{ sentToEmail }}</strong> exists, you will receive password reset instructions shortly.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-(--ui-text-dimmed)">
|
||||||
|
Check your email inbox and spam folder.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset Form -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<!-- Error Alert -->
|
||||||
|
<UAlert
|
||||||
|
v-if="resetError"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
:title="resetError"
|
||||||
|
@close="resetError = ''"
|
||||||
|
:close-button="{ icon: 'i-lucide-x', color: 'gray', variant: 'ghost' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Email Input -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<UFormField label="Email" name="email" required>
|
||||||
|
<UInput
|
||||||
|
v-model="resetEmail"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
size="lg"
|
||||||
|
:disabled="resetLoading"
|
||||||
|
autocomplete="email"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
:loading="resetLoading"
|
||||||
|
:disabled="resetLoading || !resetEmail"
|
||||||
|
>
|
||||||
|
Send Reset Link
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back to Login -->
|
||||||
|
<div class="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="switchToLogin"
|
||||||
|
class="text-sm text-(--ui-text-muted) hover:text-(--ui-text) transition-colors"
|
||||||
|
:disabled="resetLoading"
|
||||||
|
>
|
||||||
|
← Back to Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<p class="text-center text-sm text-(--ui-text-dimmed) mt-8">
|
||||||
|
By signing in, you agree to our Terms of Service and Privacy Policy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
464
app/pages/profile.vue
Normal file
464
app/pages/profile.vue
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { user, logout, deleteAccount } = useAuth()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// Check for email change status in query params
|
||||||
|
onMounted(() => {
|
||||||
|
const emailChangeStatus = route.query.email_change as string
|
||||||
|
if (emailChangeStatus) {
|
||||||
|
switch (emailChangeStatus) {
|
||||||
|
case 'success':
|
||||||
|
emailSuccess.value = 'Email address successfully changed! You can now log in with your new email.'
|
||||||
|
break
|
||||||
|
case 'invalid_token':
|
||||||
|
emailError.value = 'Invalid or expired verification link'
|
||||||
|
break
|
||||||
|
case 'no_pending':
|
||||||
|
emailError.value = 'No pending email change found'
|
||||||
|
break
|
||||||
|
case 'email_taken':
|
||||||
|
emailError.value = 'The new email address is already in use'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// State for name change
|
||||||
|
const nameState = reactive({
|
||||||
|
display_name: user.value?.display_name || ''
|
||||||
|
})
|
||||||
|
const nameLoading = ref(false)
|
||||||
|
const nameError = ref('')
|
||||||
|
const nameSuccess = ref('')
|
||||||
|
|
||||||
|
// State for email change
|
||||||
|
const emailState = reactive({
|
||||||
|
new_email: '',
|
||||||
|
current_password: ''
|
||||||
|
})
|
||||||
|
const emailLoading = ref(false)
|
||||||
|
const emailError = ref('')
|
||||||
|
const emailSuccess = ref('')
|
||||||
|
|
||||||
|
const canSubmitEmail = computed(() => {
|
||||||
|
return emailState.new_email.trim() &&
|
||||||
|
emailState.current_password &&
|
||||||
|
!emailLoading.value &&
|
||||||
|
emailState.new_email !== user.value?.email
|
||||||
|
})
|
||||||
|
|
||||||
|
// State for password change
|
||||||
|
const passwordState = reactive({
|
||||||
|
current_password: '',
|
||||||
|
new_password: '',
|
||||||
|
confirm_password: ''
|
||||||
|
})
|
||||||
|
const passwordLoading = ref(false)
|
||||||
|
const passwordError = ref('')
|
||||||
|
const passwordSuccess = ref('')
|
||||||
|
|
||||||
|
// State for account deletion
|
||||||
|
const deleteState = reactive({
|
||||||
|
password: '',
|
||||||
|
confirmDelete: false
|
||||||
|
})
|
||||||
|
const deleteLoading = ref(false)
|
||||||
|
const deleteError = ref('')
|
||||||
|
|
||||||
|
// Watch user changes to update name field
|
||||||
|
watch(user, (newUser) => {
|
||||||
|
if (newUser) {
|
||||||
|
nameState.display_name = newUser.display_name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Password validation
|
||||||
|
const passwordMatch = computed(() => {
|
||||||
|
return passwordState.new_password === passwordState.confirm_password
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordStrength = computed(() => {
|
||||||
|
const password = passwordState.new_password
|
||||||
|
if (password.length === 0) return { strength: 0, label: '', color: 'gray' }
|
||||||
|
if (password.length < 8) return { strength: 25, label: 'Weak', color: 'red' }
|
||||||
|
if (password.length < 12) return { strength: 50, label: 'Fair', color: 'yellow' }
|
||||||
|
if (!/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) {
|
||||||
|
return { strength: 50, label: 'Fair', color: 'yellow' }
|
||||||
|
}
|
||||||
|
return { strength: 100, label: 'Strong', color: 'green' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmitPassword = computed(() => {
|
||||||
|
return passwordState.current_password &&
|
||||||
|
passwordState.new_password.length >= 8 &&
|
||||||
|
passwordMatch.value &&
|
||||||
|
!passwordLoading.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle name update
|
||||||
|
async function handleNameUpdate() {
|
||||||
|
nameError.value = ''
|
||||||
|
nameSuccess.value = ''
|
||||||
|
|
||||||
|
if (!nameState.display_name.trim()) {
|
||||||
|
nameError.value = 'Display name is required'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nameLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/api/profile/name', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
display_name: nameState.display_name.trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
nameSuccess.value = 'Display name updated successfully'
|
||||||
|
|
||||||
|
// Update user in auth composable
|
||||||
|
if (user.value) {
|
||||||
|
user.value.display_name = nameState.display_name.trim()
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
nameError.value = err.data?.statusMessage || 'Failed to update display name'
|
||||||
|
} finally {
|
||||||
|
nameLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle email change
|
||||||
|
async function handleEmailChange() {
|
||||||
|
emailError.value = ''
|
||||||
|
emailSuccess.value = ''
|
||||||
|
|
||||||
|
if (!canSubmitEmail.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic email validation
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(emailState.new_email)) {
|
||||||
|
emailError.value = 'Please enter a valid email address'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emailLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/api/profile/email', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
new_email: emailState.new_email.trim(),
|
||||||
|
current_password: emailState.current_password
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
emailSuccess.value = 'Verification email sent! Please check your new email address to confirm the change.'
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
emailState.new_email = ''
|
||||||
|
emailState.current_password = ''
|
||||||
|
} catch (err: any) {
|
||||||
|
emailError.value = err.data?.statusMessage || 'Failed to request email change'
|
||||||
|
} finally {
|
||||||
|
emailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle password change
|
||||||
|
async function handlePasswordChange() {
|
||||||
|
passwordError.value = ''
|
||||||
|
passwordSuccess.value = ''
|
||||||
|
|
||||||
|
if (!canSubmitPassword.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/api/profile/password', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
current_password: passwordState.current_password,
|
||||||
|
new_password: passwordState.new_password
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
passwordSuccess.value = 'Password changed successfully'
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
passwordState.current_password = ''
|
||||||
|
passwordState.new_password = ''
|
||||||
|
passwordState.confirm_password = ''
|
||||||
|
} catch (err: any) {
|
||||||
|
passwordError.value = err.data?.statusMessage || 'Failed to change password'
|
||||||
|
} finally {
|
||||||
|
passwordLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle account deletion
|
||||||
|
async function handleDeleteAccount() {
|
||||||
|
deleteError.value = ''
|
||||||
|
|
||||||
|
if (!deleteState.password || !deleteState.confirmDelete) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deleteAccount(deleteState.password)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
deleteError.value = result.message || 'Failed to delete account'
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
deleteError.value = err.data?.statusMessage || 'Failed to delete account'
|
||||||
|
} finally {
|
||||||
|
deleteLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset delete confirmation when password changes
|
||||||
|
watch(() => deleteState.password, () => {
|
||||||
|
deleteState.confirmDelete = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
|
<h1 class="text-3xl font-bold">Profile Settings</h1>
|
||||||
|
|
||||||
|
<!-- Account Info -->
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-semibold">Account Information</h2>
|
||||||
|
</template>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">Email:</span>
|
||||||
|
<span class="ml-2 text-(--ui-text-muted)">{{ user?.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">Account created:</span>
|
||||||
|
<span class="ml-2 text-(--ui-text-muted)">
|
||||||
|
{{ new Date(user?.created).toLocaleDateString() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Change Display Name -->
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-semibold">Change Display Name</h2>
|
||||||
|
</template>
|
||||||
|
<form @submit.prevent="handleNameUpdate" class="space-y-4">
|
||||||
|
<UFormField label="Display Name">
|
||||||
|
<UInput
|
||||||
|
v-model="nameState.display_name"
|
||||||
|
type="text"
|
||||||
|
size="lg"
|
||||||
|
:disabled="nameLoading"
|
||||||
|
placeholder="Enter your display name"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UAlert v-if="nameError" color="error" :title="nameError" />
|
||||||
|
<UAlert v-if="nameSuccess" color="success" :title="nameSuccess" />
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
:loading="nameLoading"
|
||||||
|
:disabled="!nameState.display_name.trim() || nameLoading"
|
||||||
|
>
|
||||||
|
Update Name
|
||||||
|
</UButton>
|
||||||
|
</form>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Change Email -->
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-semibold">Change Email Address</h2>
|
||||||
|
</template>
|
||||||
|
<form @submit.prevent="handleEmailChange" class="space-y-4">
|
||||||
|
<div class="text-sm text-(--ui-text-muted) mb-4">
|
||||||
|
<p>Current email: <strong>{{ user?.email }}</strong></p>
|
||||||
|
<p class="mt-2">A verification link will be sent to your new email address.</p>
|
||||||
|
</div>
|
||||||
|
<UFormField label="New Email Address">
|
||||||
|
<UInput
|
||||||
|
v-model="emailState.new_email"
|
||||||
|
type="email"
|
||||||
|
size="lg"
|
||||||
|
:disabled="emailLoading"
|
||||||
|
placeholder="Enter new email address"
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Current Password">
|
||||||
|
<UInput
|
||||||
|
v-model="emailState.current_password"
|
||||||
|
type="password"
|
||||||
|
size="lg"
|
||||||
|
:disabled="emailLoading"
|
||||||
|
placeholder="Enter your current password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UAlert v-if="emailError" color="error" :title="emailError" />
|
||||||
|
<UAlert v-if="emailSuccess" color="success" :title="emailSuccess" />
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
:loading="emailLoading"
|
||||||
|
:disabled="!canSubmitEmail"
|
||||||
|
>
|
||||||
|
Send Verification Email
|
||||||
|
</UButton>
|
||||||
|
</form>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Change Password -->
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-semibold">Change Password</h2>
|
||||||
|
</template>
|
||||||
|
<form @submit.prevent="handlePasswordChange" class="space-y-4">
|
||||||
|
<UFormField label="Current Password">
|
||||||
|
<UInput
|
||||||
|
v-model="passwordState.current_password"
|
||||||
|
type="password"
|
||||||
|
size="lg"
|
||||||
|
:disabled="passwordLoading"
|
||||||
|
placeholder="Enter current password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="New Password">
|
||||||
|
<UInput
|
||||||
|
v-model="passwordState.new_password"
|
||||||
|
type="password"
|
||||||
|
size="lg"
|
||||||
|
:disabled="passwordLoading"
|
||||||
|
placeholder="Enter new password (min 8 characters)"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<!-- Password Strength Indicator -->
|
||||||
|
<div v-if="passwordState.new_password" class="space-y-2">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span>Password Strength:</span>
|
||||||
|
<span :class="{
|
||||||
|
'text-red-600': passwordStrength.color === 'red',
|
||||||
|
'text-yellow-600': passwordStrength.color === 'yellow',
|
||||||
|
'text-green-600': passwordStrength.color === 'green'
|
||||||
|
}">
|
||||||
|
{{ passwordStrength.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 bg-(--ui-bg-accented) rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full transition-all duration-300"
|
||||||
|
:class="{
|
||||||
|
'bg-red-500': passwordStrength.color === 'red',
|
||||||
|
'bg-yellow-500': passwordStrength.color === 'yellow',
|
||||||
|
'bg-green-500': passwordStrength.color === 'green'
|
||||||
|
}"
|
||||||
|
:style="{ width: passwordStrength.strength + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UFormField label="Confirm New Password">
|
||||||
|
<UInput
|
||||||
|
v-model="passwordState.confirm_password"
|
||||||
|
type="password"
|
||||||
|
size="lg"
|
||||||
|
:disabled="passwordLoading"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<!-- Password Match Indicator -->
|
||||||
|
<div v-if="passwordState.confirm_password" class="text-sm">
|
||||||
|
<span v-if="passwordMatch" class="text-green-600">Passwords match</span>
|
||||||
|
<span v-else class="text-red-600">Passwords do not match</span>
|
||||||
|
</div>
|
||||||
|
<UAlert v-if="passwordError" color="error" :title="passwordError" />
|
||||||
|
<UAlert v-if="passwordSuccess" color="success" :title="passwordSuccess" />
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
:loading="passwordLoading"
|
||||||
|
:disabled="!canSubmitPassword"
|
||||||
|
>
|
||||||
|
Change Password
|
||||||
|
</UButton>
|
||||||
|
</form>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Sign Out -->
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-semibold">Sign Out</h2>
|
||||||
|
</template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-(--ui-text-muted)">
|
||||||
|
Sign out of your account on this device.
|
||||||
|
</p>
|
||||||
|
<UButton color="error" variant="outline" size="lg" @click="logout">
|
||||||
|
Sign Out
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Delete Account -->
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-semibold text-red-600">Delete Account</h2>
|
||||||
|
</template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<UAlert
|
||||||
|
color="warning"
|
||||||
|
title="Warning: This action is permanent"
|
||||||
|
description="Deleting your account will permanently remove all your data. This action cannot be undone."
|
||||||
|
/>
|
||||||
|
<UFormField label="Enter your password to confirm">
|
||||||
|
<UInput
|
||||||
|
v-model="deleteState.password"
|
||||||
|
type="password"
|
||||||
|
size="lg"
|
||||||
|
:disabled="deleteLoading"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UCheckbox
|
||||||
|
v-model="deleteState.confirmDelete"
|
||||||
|
:disabled="!deleteState.password || deleteLoading"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">I understand this action is permanent and cannot be undone</span>
|
||||||
|
</div>
|
||||||
|
<UAlert v-if="deleteError" color="error" :title="deleteError" />
|
||||||
|
<UButton
|
||||||
|
color="error"
|
||||||
|
size="lg"
|
||||||
|
:loading="deleteLoading"
|
||||||
|
:disabled="!deleteState.password || !deleteState.confirmDelete || deleteLoading"
|
||||||
|
@click="handleDeleteAccount"
|
||||||
|
>
|
||||||
|
Delete My Account
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
253
app/pages/register.vue
Normal file
253
app/pages/register.vue
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const { register } = useAuth()
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
display_name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref(false)
|
||||||
|
|
||||||
|
const passwordMatch = computed(() => {
|
||||||
|
if (!state.confirmPassword) return true
|
||||||
|
return state.password === state.confirmPassword
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordStrength = computed(() => {
|
||||||
|
const password = state.password
|
||||||
|
if (!password) return { strength: 0, label: '', color: 'gray' }
|
||||||
|
|
||||||
|
let strength = 0
|
||||||
|
if (password.length >= 8) strength++
|
||||||
|
if (password.length >= 12) strength++
|
||||||
|
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++
|
||||||
|
if (/\d/.test(password)) strength++
|
||||||
|
if (/[^a-zA-Z0-9]/.test(password)) strength++
|
||||||
|
|
||||||
|
const labels = ['Weak', 'Fair', 'Good', 'Strong', 'Very Strong']
|
||||||
|
const colors = ['red', 'orange', 'yellow', 'green', 'emerald']
|
||||||
|
|
||||||
|
return {
|
||||||
|
strength: (strength / 5) * 100,
|
||||||
|
label: labels[strength - 1] || 'Weak',
|
||||||
|
color: colors[strength - 1] || 'red'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (state.display_name.length < 2) {
|
||||||
|
error.value = 'Display name must be at least 2 characters long'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.password.length < 8) {
|
||||||
|
error.value = 'Password must be at least 8 characters long'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.password !== state.confirmPassword) {
|
||||||
|
error.value = 'Passwords do not match'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await register(state.email, state.password, state.display_name) as { success: boolean; message?: string; autoLoggedIn?: boolean }
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// First user is auto-logged-in by the server; skip the "check your email" UI.
|
||||||
|
if (result.autoLoggedIn) {
|
||||||
|
await navigateTo('/dashboard', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success.value = true
|
||||||
|
} else {
|
||||||
|
error.value = result.message || 'Registration failed. Please try again.'
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || 'An error occurred during registration. Please try again.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectToLogin = () => {
|
||||||
|
const redirect = route.query.redirect as string
|
||||||
|
if (redirect) {
|
||||||
|
router.push(`/login?redirect=${encodeURIComponent(redirect)}`)
|
||||||
|
} else {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Logo/Header -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-(--ui-text) mb-2">Create Account</h1>
|
||||||
|
<p class="text-(--ui-text-muted)">Join us today</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Register Card -->
|
||||||
|
<UCard :ui="{ body: 'p-6 sm:p-8' }">
|
||||||
|
<!-- Success Message -->
|
||||||
|
<div v-if="success" class="space-y-4">
|
||||||
|
<UAlert
|
||||||
|
color="success"
|
||||||
|
variant="soft"
|
||||||
|
title="Registration Successful!"
|
||||||
|
description="Please check your email to verify your account."
|
||||||
|
:ui="{ icon: 'i-lucide-check-circle' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Registration Form -->
|
||||||
|
<form v-else @submit.prevent="handleRegister" class="space-y-6">
|
||||||
|
<!-- Error Alert -->
|
||||||
|
<UAlert
|
||||||
|
v-if="error"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
:title="error"
|
||||||
|
@close="error = ''"
|
||||||
|
:close-button="{ icon: 'i-lucide-x', color: 'gray', variant: 'ghost' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Display Name Input -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<UFormField label="Display Name" name="display_name" required>
|
||||||
|
<UInput
|
||||||
|
v-model="state.display_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name"
|
||||||
|
size="lg"
|
||||||
|
:disabled="loading"
|
||||||
|
autocomplete="name"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email Input -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<UFormField label="Email" name="email" required>
|
||||||
|
<UInput
|
||||||
|
v-model="state.email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
size="lg"
|
||||||
|
:disabled="loading"
|
||||||
|
autocomplete="email"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Input -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<UFormField label="Password" name="password" required>
|
||||||
|
<UInput
|
||||||
|
v-model="state.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Create a strong password"
|
||||||
|
size="lg"
|
||||||
|
:disabled="loading"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Password Strength Indicator -->
|
||||||
|
<div v-if="state.password" class="mt-2 space-y-1">
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="text-(--ui-text-muted)">Password strength:</span>
|
||||||
|
<span :class="`font-medium text-${passwordStrength.color}-600`">
|
||||||
|
{{ passwordStrength.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 bg-(--ui-bg-accented) rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
:class="`h-full bg-${passwordStrength.color}-500 transition-all duration-300`"
|
||||||
|
:style="{ width: `${passwordStrength.strength}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Password Input -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<UFormField label="Confirm Password" name="confirmPassword" required>
|
||||||
|
<UInput
|
||||||
|
v-model="state.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
size="lg"
|
||||||
|
:disabled="loading"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:ui="{ base: 'px-3 py-2' }"
|
||||||
|
:color="!passwordMatch ? 'error' : undefined"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<p v-if="!passwordMatch" class="text-xs text-red-600 mt-1">
|
||||||
|
Passwords do not match
|
||||||
|
</p>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="loading || !state.email || !state.password || !state.display_name || !passwordMatch"
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<div class="w-full border-t border-(--ui-border)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-sm">
|
||||||
|
<span class="px-2 bg-(--ui-bg) text-(--ui-text-muted)">Already have an account?</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Link -->
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
@click="redirectToLogin"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</UButton>
|
||||||
|
</form>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<p class="text-center text-sm text-(--ui-text-dimmed) mt-8">
|
||||||
|
By creating an account, you agree to our Terms of Service and Privacy Policy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
189
app/pages/reset-password.vue
Normal file
189
app/pages/reset-password.vue
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'auth'
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const token = computed(() => route.query.token as string)
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
loading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref(false)
|
||||||
|
const invalidToken = ref(false)
|
||||||
|
|
||||||
|
// Validate token on mount
|
||||||
|
onMounted(() => {
|
||||||
|
if (!token.value) {
|
||||||
|
invalidToken.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleResetPassword() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
// Validate passwords
|
||||||
|
if (state.password.length < 6) {
|
||||||
|
error.value = 'Password must be at least 6 characters'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.password !== state.confirmPassword) {
|
||||||
|
error.value = 'Passwords do not match'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/auth/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
token: token.value,
|
||||||
|
password: state.password,
|
||||||
|
confirmPassword: state.confirmPassword
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
success.value = true
|
||||||
|
|
||||||
|
// Redirect to login after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/login')
|
||||||
|
}, 2000)
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || 'An error occurred. Please try again.'
|
||||||
|
} finally {
|
||||||
|
state.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Logo/Header -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-(--ui-text) mb-2">Reset Password</h1>
|
||||||
|
<p class="text-(--ui-text-muted)">Enter your new password</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Invalid Token View -->
|
||||||
|
<UCard v-if="invalidToken" :ui="{ body: 'p-6 sm:p-8' }">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<UAlert
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
title="Invalid Reset Link"
|
||||||
|
/>
|
||||||
|
<p class="text-(--ui-text-muted)">
|
||||||
|
This password reset link is invalid or has expired. Please request a new password reset.
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
color="primary"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
@click="router.push('/login')"
|
||||||
|
>
|
||||||
|
Back to Login
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Success View -->
|
||||||
|
<UCard v-else-if="success" :ui="{ body: 'p-6 sm:p-8' }">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<UAlert
|
||||||
|
color="success"
|
||||||
|
variant="soft"
|
||||||
|
title="Password Reset Successfully!"
|
||||||
|
/>
|
||||||
|
<p class="text-(--ui-text-muted)">
|
||||||
|
Your password has been reset. You can now log in with your new password.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-(--ui-text-dimmed)">
|
||||||
|
Redirecting to login page...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Reset Form -->
|
||||||
|
<UCard v-else :ui="{ body: 'p-6 sm:p-8' }">
|
||||||
|
<form @submit.prevent="handleResetPassword" class="space-y-6">
|
||||||
|
<!-- Error Alert -->
|
||||||
|
<UAlert
|
||||||
|
v-if="error"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
:title="error"
|
||||||
|
@close="error = ''"
|
||||||
|
:close-button="{ icon: 'i-lucide-x', color: 'gray', variant: 'ghost' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Password Input -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<UFormField label="New Password" name="password" required>
|
||||||
|
<UInput
|
||||||
|
v-model="state.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter new password (min. 6 characters)"
|
||||||
|
size="lg"
|
||||||
|
:disabled="state.loading"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Password Input -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<UFormField label="Confirm Password" name="confirmPassword" required>
|
||||||
|
<UInput
|
||||||
|
v-model="state.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
size="lg"
|
||||||
|
:disabled="state.loading"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
:loading="state.loading"
|
||||||
|
:disabled="state.loading || !state.password || !state.confirmPassword"
|
||||||
|
>
|
||||||
|
Reset Password
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<!-- Back to Login -->
|
||||||
|
<div class="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="router.push('/login')"
|
||||||
|
class="text-sm text-(--ui-text-muted) hover:text-(--ui-text) transition-colors"
|
||||||
|
:disabled="state.loading"
|
||||||
|
>
|
||||||
|
← Back to Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<p class="text-center text-sm text-(--ui-text-dimmed) mt-8">
|
||||||
|
By resetting your password, you agree to our Terms of Service and Privacy Policy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
12
app/plugins/auth.client.ts
Normal file
12
app/plugins/auth.client.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export default defineNuxtPlugin(async () => {
|
||||||
|
const { restoreFromCache, checkAuth, setAuthReady } = useAuth()
|
||||||
|
|
||||||
|
restoreFromCache()
|
||||||
|
|
||||||
|
const hasAuthCookie = document.cookie.includes('auth-token')
|
||||||
|
if (hasAuthCookie) {
|
||||||
|
await checkAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthReady(true)
|
||||||
|
})
|
||||||
44
app/utils/permissions.ts
Normal file
44
app/utils/permissions.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Central permission registry.
|
||||||
|
//
|
||||||
|
// When porting to nuxt-blueprints, each block ships a `permissions.ts` fragment
|
||||||
|
// and blueprint assembly merges the fragments into this file. Consumer projects
|
||||||
|
// own this file and are free to edit it.
|
||||||
|
export const PERMISSIONS = [
|
||||||
|
'admin.access',
|
||||||
|
'users.view',
|
||||||
|
'users.manage',
|
||||||
|
'roles.view',
|
||||||
|
'roles.manage'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type Permission = typeof PERMISSIONS[number]
|
||||||
|
|
||||||
|
// Optional: human-readable titles and descriptions shown in admin UI.
|
||||||
|
// Keys must match entries in PERMISSIONS. Missing entries fall back to the raw
|
||||||
|
// permission string.
|
||||||
|
export const PERMISSION_META: Record<string, { title: string; description: string }> = {
|
||||||
|
'admin.access': {
|
||||||
|
title: 'Access admin area',
|
||||||
|
description: 'Required to reach /admin and see the admin shell.'
|
||||||
|
},
|
||||||
|
'users.view': {
|
||||||
|
title: 'View users',
|
||||||
|
description: 'See the users list.'
|
||||||
|
},
|
||||||
|
'users.manage': {
|
||||||
|
title: 'Manage users',
|
||||||
|
description: 'Edit, delete, and assign roles to other users (subject to the subset delegation rule).'
|
||||||
|
},
|
||||||
|
'roles.view': {
|
||||||
|
title: 'View roles',
|
||||||
|
description: 'See the roles reference page.'
|
||||||
|
},
|
||||||
|
'roles.manage': {
|
||||||
|
title: 'Manage roles',
|
||||||
|
description: 'Create, edit, and delete custom roles.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPermission(value: string): value is Permission {
|
||||||
|
return (PERMISSIONS as readonly string[]).includes(value)
|
||||||
|
}
|
||||||
32
app/utils/role-definitions.ts
Normal file
32
app/utils/role-definitions.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { PERMISSIONS, type Permission } from './permissions'
|
||||||
|
|
||||||
|
export interface RoleDefinition {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
permissions: readonly Permission[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROLES = {
|
||||||
|
admin: {
|
||||||
|
name: 'admin',
|
||||||
|
description: 'Full access to all features and user management.',
|
||||||
|
permissions: [...PERMISSIONS]
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
name: 'member',
|
||||||
|
description: 'Basic authenticated access.',
|
||||||
|
permissions: []
|
||||||
|
}
|
||||||
|
} as const satisfies Record<string, RoleDefinition>
|
||||||
|
|
||||||
|
export type StaticRoleName = keyof typeof ROLES
|
||||||
|
|
||||||
|
export const STATIC_ROLE_NAMES = Object.keys(ROLES) as StaticRoleName[]
|
||||||
|
|
||||||
|
export function getStaticRole(name: string): RoleDefinition | null {
|
||||||
|
return (ROLES as Record<string, RoleDefinition>)[name] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStaticRole(name: string): name is StaticRoleName {
|
||||||
|
return name in ROLES
|
||||||
|
}
|
||||||
6
eslint.config.mjs
Normal file
6
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// @ts-check
|
||||||
|
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||||
|
|
||||||
|
export default withNuxt(
|
||||||
|
// Your custom configs here
|
||||||
|
)
|
||||||
18
migrations/001_create_users_table.ts
Normal file
18
migrations/001_create_users_table.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Kysely, sql } from 'kysely'
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('users')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
|
||||||
|
.addColumn('created', 'timestamptz', col => col.defaultTo(sql`now()`))
|
||||||
|
.addColumn('updated', 'timestamptz', col => col.defaultTo(sql`now()`))
|
||||||
|
.addColumn('email', 'text', col => col.unique().notNull())
|
||||||
|
.addColumn('display_name', 'text', col => col.notNull())
|
||||||
|
.addColumn('avatar', 'text', col => col.defaultTo(''))
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('users').ifExists().execute()
|
||||||
|
}
|
||||||
29
migrations/002_add_auth_fields.ts
Normal file
29
migrations/002_add_auth_fields.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Kysely, sql, type ColumnDefinitionBuilder } from 'kysely'
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('users')
|
||||||
|
.addColumn('password', 'text', col => col.notNull().defaultTo(''))
|
||||||
|
.addColumn('verified', 'boolean', col => col.defaultTo(false))
|
||||||
|
.addColumn(
|
||||||
|
'roles',
|
||||||
|
sql`text[]`,
|
||||||
|
(col: ColumnDefinitionBuilder) => col.notNull().defaultTo(sql`'{}'::text[]`)
|
||||||
|
)
|
||||||
|
.addColumn('token_key', 'uuid', col => col.defaultTo(sql`gen_random_uuid()`))
|
||||||
|
.addColumn('pending_email', 'text')
|
||||||
|
.addColumn('email_change_token', 'uuid')
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('users')
|
||||||
|
.dropColumn('password')
|
||||||
|
.dropColumn('verified')
|
||||||
|
.dropColumn('roles')
|
||||||
|
.dropColumn('token_key')
|
||||||
|
.dropColumn('pending_email')
|
||||||
|
.dropColumn('email_change_token')
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
43
migrations/003_create_password_reset_table.ts
Normal file
43
migrations/003_create_password_reset_table.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Kysely, sql } from 'kysely'
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('password_reset_requests')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
|
||||||
|
.addColumn('created', 'timestamptz', col => col.notNull().defaultTo(sql`current_timestamp`))
|
||||||
|
.addColumn('expires', 'timestamptz', col => col.notNull())
|
||||||
|
.addColumn('user_id', 'uuid', col => col.notNull().references('users.id').onDelete('cascade'))
|
||||||
|
.addColumn('token', 'text', col => col.notNull().unique())
|
||||||
|
.addColumn('used', 'boolean', col => col.notNull().defaultTo(false))
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('password_reset_requests_token_idx')
|
||||||
|
.unique()
|
||||||
|
.on('password_reset_requests')
|
||||||
|
.column('token')
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('password_reset_requests_user_id_idx')
|
||||||
|
.on('password_reset_requests')
|
||||||
|
.column('user_id')
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('password_reset_requests_expires_idx')
|
||||||
|
.on('password_reset_requests')
|
||||||
|
.column('expires')
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('password_reset_requests_used_idx')
|
||||||
|
.on('password_reset_requests')
|
||||||
|
.column('used')
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('password_reset_requests').ifExists().execute()
|
||||||
|
}
|
||||||
41
migrations/004_create_activity_logs_table.ts
Normal file
41
migrations/004_create_activity_logs_table.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { Kysely, sql } from 'kysely'
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('activity_logs')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
|
||||||
|
.addColumn('timestamp', 'timestamptz', col => col.notNull().defaultTo(sql`now()`))
|
||||||
|
.addColumn('event_type', 'text', col => col.notNull())
|
||||||
|
.addColumn('table_name', 'text')
|
||||||
|
.addColumn('record_id', 'text')
|
||||||
|
.addColumn('user_id', 'uuid')
|
||||||
|
.addColumn('user_agent', 'text')
|
||||||
|
.addColumn('metadata', 'jsonb', col => col.defaultTo(sql`'{}'::jsonb`))
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_activity_logs_timestamp')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('activity_logs')
|
||||||
|
.expression(sql`timestamp DESC`)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_activity_logs_user_id')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('activity_logs')
|
||||||
|
.column('user_id')
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_activity_logs_event_type')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('activity_logs')
|
||||||
|
.column('event_type')
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('activity_logs').ifExists().execute()
|
||||||
|
}
|
||||||
67
nuxt.config.ts
Normal file
67
nuxt.config.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
modules: [
|
||||||
|
'@nuxt/eslint',
|
||||||
|
'@nuxt/ui'
|
||||||
|
],
|
||||||
|
|
||||||
|
devtools: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
|
||||||
|
devServer: {
|
||||||
|
port: 3033
|
||||||
|
},
|
||||||
|
|
||||||
|
ssr: false,
|
||||||
|
|
||||||
|
css: ['~/assets/css/main.css'],
|
||||||
|
|
||||||
|
ui: {
|
||||||
|
theme: {
|
||||||
|
colors: ['primary', 'secondary', 'info', 'success', 'warning', 'error', 'neutral']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: process.env.APP_TITLE || 'My App'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
routeRules: {
|
||||||
|
'/': { prerender: true }
|
||||||
|
},
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
appName: process.env.APP_TITLE || 'My App',
|
||||||
|
databaseUrl: process.env.DATABASE_URL || '',
|
||||||
|
jwtSecret: process.env.JWT_SECRET || '',
|
||||||
|
mailgunApiKey: process.env.MAILGUN_API_KEY || '',
|
||||||
|
mailgunDomain: process.env.MAILGUN_DOMAIN || '',
|
||||||
|
mailgunHost: process.env.MAILGUN_HOST || '',
|
||||||
|
smtpFrom: process.env.SMTP_FROM || '',
|
||||||
|
smtpFromName: process.env.SMTP_FROM_NAME || '',
|
||||||
|
s3Endpoint: process.env.S3_ENDPOINT || '',
|
||||||
|
s3Region: process.env.S3_REGION || '',
|
||||||
|
s3AccessKeyId: process.env.S3_ACCESS_KEY_ID || '',
|
||||||
|
s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '',
|
||||||
|
s3BucketName: process.env.S3_BUCKET_NAME || '',
|
||||||
|
public: {
|
||||||
|
appName: process.env.APP_TITLE || 'My App',
|
||||||
|
nodeEnv: process.env.NODE_ENV || '',
|
||||||
|
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
compatibilityDate: '2025-01-15',
|
||||||
|
|
||||||
|
eslint: {
|
||||||
|
config: {
|
||||||
|
stylistic: {
|
||||||
|
commaDangle: 'never',
|
||||||
|
braceStyle: '1tbs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
39
package.json
Normal file
39
package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"name": "marketing-rebuild",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "nuxt typecheck"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.914.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.914.0",
|
||||||
|
"@iconify-json/lucide": "^1.2.102",
|
||||||
|
"@iconify-json/simple-icons": "^1.2.78",
|
||||||
|
"@nuxt/ui": "^4.6.1",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"kysely": "^0.28.0",
|
||||||
|
"kysely-postgres-js": "^2.0.0",
|
||||||
|
"nodemailer": "^7.0.6",
|
||||||
|
"nodemailer-mailgun-transport": "^2.1.5",
|
||||||
|
"nuxt": "^4.4.2",
|
||||||
|
"postgres": "^3.4.7",
|
||||||
|
"tailwindcss": "^4.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxt/eslint": "^1.15.2",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/nodemailer": "^7.0.1",
|
||||||
|
"@types/nodemailer-mailgun-transport": "^1.4.6",
|
||||||
|
"eslint": "^10.2.0",
|
||||||
|
"typescript": "^6.0.2",
|
||||||
|
"vue-tsc": "^3.2.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
81
server/api/admin/users.get.ts
Normal file
81
server/api/admin/users.get.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { getQuery } from 'h3'
|
||||||
|
import { db, sql } from '../../utils/database'
|
||||||
|
import { requirePermission } from '../../utils/rbac'
|
||||||
|
|
||||||
|
const SORTABLE_COLUMNS = ['display_name', 'email', 'verified', 'created', 'last_login'] as const
|
||||||
|
type SortColumn = typeof SORTABLE_COLUMNS[number]
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requirePermission(event, 'users.view')
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
|
||||||
|
const pageRaw = Number(query.page ?? 1)
|
||||||
|
const pageSizeRaw = Number(query.pageSize ?? 50)
|
||||||
|
const page = Number.isFinite(pageRaw) && pageRaw >= 1 ? Math.floor(pageRaw) : 1
|
||||||
|
const pageSize = Number.isFinite(pageSizeRaw) && pageSizeRaw >= 1 && pageSizeRaw <= 200
|
||||||
|
? Math.floor(pageSizeRaw)
|
||||||
|
: 50
|
||||||
|
|
||||||
|
const q = typeof query.q === 'string' ? query.q.trim() : ''
|
||||||
|
|
||||||
|
const sortParam = typeof query.sort === 'string' ? query.sort : 'created'
|
||||||
|
const sort: SortColumn = (SORTABLE_COLUMNS as readonly string[]).includes(sortParam)
|
||||||
|
? sortParam as SortColumn
|
||||||
|
: 'created'
|
||||||
|
const dir: 'asc' | 'desc' = query.dir === 'asc' ? 'asc' : 'desc'
|
||||||
|
|
||||||
|
const pattern = q ? `%${q}%` : null
|
||||||
|
|
||||||
|
const totalRow = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(eb => eb.fn.countAll<string>().as('count'))
|
||||||
|
.$if(!!pattern, qb => qb.where(eb => eb.or([
|
||||||
|
eb('users.display_name', 'ilike', pattern!),
|
||||||
|
eb('users.email', 'ilike', pattern!)
|
||||||
|
])))
|
||||||
|
.executeTakeFirst()
|
||||||
|
const total = Number(totalRow?.count ?? 0)
|
||||||
|
|
||||||
|
const orderExpr = sort === 'last_login'
|
||||||
|
? sql`last_login ${sql.raw(dir)} nulls last`
|
||||||
|
: sort === 'display_name'
|
||||||
|
? sql`users.display_name ${sql.raw(dir)}`
|
||||||
|
: sort === 'email'
|
||||||
|
? sql`users.email ${sql.raw(dir)}`
|
||||||
|
: sort === 'verified'
|
||||||
|
? sql`users.verified ${sql.raw(dir)}`
|
||||||
|
: sql`users.created ${sql.raw(dir)}`
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select([
|
||||||
|
'users.id',
|
||||||
|
'users.display_name',
|
||||||
|
'users.email',
|
||||||
|
'users.verified',
|
||||||
|
'users.created',
|
||||||
|
'users.roles',
|
||||||
|
eb => eb
|
||||||
|
.selectFrom('activity_logs')
|
||||||
|
.select(eb2 => eb2.fn.max('activity_logs.timestamp').as('last_login'))
|
||||||
|
.where('activity_logs.event_type', '=', 'LOGIN')
|
||||||
|
.whereRef('activity_logs.user_id', '=', 'users.id')
|
||||||
|
.as('last_login')
|
||||||
|
])
|
||||||
|
.$if(!!pattern, qb => qb.where(eb => eb.or([
|
||||||
|
eb('users.display_name', 'ilike', pattern!),
|
||||||
|
eb('users.email', 'ilike', pattern!)
|
||||||
|
])))
|
||||||
|
.orderBy(orderExpr)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset((page - 1) * pageSize)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
})
|
||||||
60
server/api/admin/users/[id].delete.ts
Normal file
60
server/api/admin/users/[id].delete.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { getRouterParam, getHeader } from 'h3'
|
||||||
|
import { db, sql } from '../../../utils/database'
|
||||||
|
import { requirePermission } from '../../../utils/rbac'
|
||||||
|
import { logEvent } from '../../../utils/activity-logger'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const admin = await requirePermission(event, 'users.manage')
|
||||||
|
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
if (!id) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'User id is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === admin.userId) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'You cannot delete your own account' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id', 'email', 'display_name', 'roles'])
|
||||||
|
.where('id', '=', id)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'User not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.roles.includes('admin')) {
|
||||||
|
const countRow = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(eb => eb.fn.count<string>('id').as('count'))
|
||||||
|
.where(sql<boolean>`'admin' = ANY(roles)`)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
const adminCount = Number(countRow?.count ?? 0)
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'Cannot delete the last admin' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction().execute(async (trx) => {
|
||||||
|
await trx.deleteFrom('password_reset_requests').where('user_id', '=', id).execute()
|
||||||
|
await trx.deleteFrom('users').where('id', '=', id).execute()
|
||||||
|
})
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
eventType: 'admin_delete_user',
|
||||||
|
tableName: 'users',
|
||||||
|
recordId: id,
|
||||||
|
userId: admin.userId,
|
||||||
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
|
metadata: {
|
||||||
|
email: target.email,
|
||||||
|
display_name: target.display_name,
|
||||||
|
roles: target.roles
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
73
server/api/admin/users/[id].patch.ts
Normal file
73
server/api/admin/users/[id].patch.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { readBody, getRouterParam, getHeader } from 'h3'
|
||||||
|
import { db } from '../../../utils/database'
|
||||||
|
import { requirePermission } from '../../../utils/rbac'
|
||||||
|
import { logEvent } from '../../../utils/activity-logger'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const admin = await requirePermission(event, 'users.manage')
|
||||||
|
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
if (!id) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'User id is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event)
|
||||||
|
const rawName = typeof body?.display_name === 'string' ? body.display_name : ''
|
||||||
|
const next = rawName.trim()
|
||||||
|
|
||||||
|
if (next.length < 2) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Display name must be at least 2 characters' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id', 'display_name'])
|
||||||
|
.where('id', '=', id)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'User not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.display_name !== next) {
|
||||||
|
await db
|
||||||
|
.updateTable('users')
|
||||||
|
.set({ display_name: next, updated: new Date().toISOString() })
|
||||||
|
.where('id', '=', id)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
eventType: 'admin_update_user',
|
||||||
|
tableName: 'users',
|
||||||
|
recordId: id,
|
||||||
|
userId: admin.userId,
|
||||||
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
|
metadata: {
|
||||||
|
field: 'display_name',
|
||||||
|
from: existing.display_name,
|
||||||
|
to: next
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select([
|
||||||
|
'users.id',
|
||||||
|
'users.display_name',
|
||||||
|
'users.email',
|
||||||
|
'users.verified',
|
||||||
|
'users.created',
|
||||||
|
'users.roles',
|
||||||
|
eb => eb
|
||||||
|
.selectFrom('activity_logs')
|
||||||
|
.select(eb2 => eb2.fn.max('activity_logs.timestamp').as('last_login'))
|
||||||
|
.where('activity_logs.event_type', '=', 'LOGIN')
|
||||||
|
.whereRef('activity_logs.user_id', '=', 'users.id')
|
||||||
|
.as('last_login')
|
||||||
|
])
|
||||||
|
.where('users.id', '=', id)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
return { user: updated }
|
||||||
|
})
|
||||||
101
server/api/admin/users/[id]/roles.put.ts
Normal file
101
server/api/admin/users/[id]/roles.put.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { readBody, getRouterParam, getHeader } from 'h3'
|
||||||
|
import { db, sql } from '../../../../utils/database'
|
||||||
|
import {
|
||||||
|
requirePermission,
|
||||||
|
validateRoleNames,
|
||||||
|
getRolePermissions,
|
||||||
|
getUserPermissions
|
||||||
|
} from '../../../../utils/rbac'
|
||||||
|
import { logEvent } from '../../../../utils/activity-logger'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const admin = await requirePermission(event, 'users.manage')
|
||||||
|
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
if (!id) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'User id is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event)
|
||||||
|
const input = Array.isArray(body?.roles) ? body.roles : null
|
||||||
|
if (!input || input.some((r: unknown) => typeof r !== 'string')) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'roles must be an array of strings' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = Array.from(new Set(input as string[]))
|
||||||
|
|
||||||
|
const { valid, unknown } = await validateRoleNames(roles)
|
||||||
|
if (!valid) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: `Unknown role(s): ${unknown.join(', ')}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id', 'roles'])
|
||||||
|
.where('id', '=', id)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'User not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const prior = existing.roles
|
||||||
|
const priorSet = new Set(prior)
|
||||||
|
const nextSet = new Set(roles)
|
||||||
|
const removed = prior.filter(r => !nextSet.has(r))
|
||||||
|
const added = roles.filter(r => !priorSet.has(r))
|
||||||
|
|
||||||
|
if (added.length === 0 && removed.length === 0) {
|
||||||
|
return { user: { id: existing.id, roles: prior } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subset delegation: the assigner can only add/remove roles whose permissions
|
||||||
|
// they themselves hold. Prevents users with `users.manage` from escalating
|
||||||
|
// anyone (including themselves) into permissions they don't have.
|
||||||
|
const deltaRoleNames = Array.from(new Set([...added, ...removed]))
|
||||||
|
const [assignerPerms, deltaPerms] = await Promise.all([
|
||||||
|
getUserPermissions(admin.userId),
|
||||||
|
getRolePermissions(deltaRoleNames)
|
||||||
|
])
|
||||||
|
const missing = [...deltaPerms].filter(p => !assignerPerms.has(p))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: `Cannot assign or revoke roles granting permissions you don't hold: ${missing.join(', ')}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent removing the last admin in the system.
|
||||||
|
if (priorSet.has('admin') && !nextSet.has('admin')) {
|
||||||
|
const countRow = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(eb => eb.fn.count<string>('id').as('count'))
|
||||||
|
.where(sql<boolean>`'admin' = ANY(roles)`)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
const adminCount = Number(countRow?.count ?? 0)
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'Cannot remove the last admin' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.updateTable('users')
|
||||||
|
.set({ roles, updated: new Date().toISOString() })
|
||||||
|
.where('id', '=', id)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
eventType: 'admin_update_user_roles',
|
||||||
|
tableName: 'users',
|
||||||
|
recordId: id,
|
||||||
|
userId: admin.userId,
|
||||||
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
|
metadata: { from: prior, to: roles, added, removed }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { user: { id, roles } }
|
||||||
|
})
|
||||||
113
server/api/auth/forgot-password.post.ts
Normal file
113
server/api/auth/forgot-password.post.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { db } from '../../utils/database'
|
||||||
|
import { logPasswordResetRequest } from '../../utils/activity-logger'
|
||||||
|
import { sendTemplateEmail } from '../../utils/email'
|
||||||
|
import { checkRateLimit, logRateLimitExceeded } from '../../utils/rate-limit'
|
||||||
|
import { readBody, getHeader } from 'h3'
|
||||||
|
import { useRuntimeConfig, createError } from '#imports'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { email } = await readBody(event)
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Email is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Invalid email format' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always return success to prevent user enumeration
|
||||||
|
const successResponse = {
|
||||||
|
success: true,
|
||||||
|
message: 'If an account with that email exists, password reset instructions have been sent.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit by email
|
||||||
|
const userAgent = getHeader(event, 'user-agent') || undefined
|
||||||
|
const rateCheck = await checkRateLimit('PASSWORD_RESET_REQUEST', 'email', email, 15 * 60 * 1000, 3)
|
||||||
|
|
||||||
|
if (!rateCheck.allowed) {
|
||||||
|
logRateLimitExceeded(email, '/api/auth/forgot-password', userAgent)
|
||||||
|
// Still return success to prevent enumeration
|
||||||
|
return successResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log this password reset request
|
||||||
|
logPasswordResetRequest(email, userAgent)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if user exists
|
||||||
|
const user = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id', 'email', 'display_name'])
|
||||||
|
.where('email', '=', email.toLowerCase())
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
// If user doesn't exist, still return success (security measure)
|
||||||
|
if (!user) {
|
||||||
|
return successResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate reset token
|
||||||
|
const token = crypto.randomUUID()
|
||||||
|
const expires = new Date(Date.now() + 60 * 60 * 1000) // 1 hour from now
|
||||||
|
|
||||||
|
await db.transaction().execute(async (trx) => {
|
||||||
|
// Delete any existing unused or expired reset requests for this user
|
||||||
|
await trx
|
||||||
|
.deleteFrom('password_reset_requests')
|
||||||
|
.where('user_id', '=', user.id)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
// Create reset request
|
||||||
|
await trx
|
||||||
|
.insertInto('password_reset_requests')
|
||||||
|
.values({
|
||||||
|
user_id: user.id,
|
||||||
|
token,
|
||||||
|
expires,
|
||||||
|
used: false,
|
||||||
|
})
|
||||||
|
.execute()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get base URL from runtime config or construct from headers
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const host = getHeader(event, 'host')
|
||||||
|
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'
|
||||||
|
const baseUrl = config.public.baseUrl || `${protocol}://${host}`
|
||||||
|
|
||||||
|
// Construct reset URL
|
||||||
|
const resetUrl = `${baseUrl}/reset-password?token=${token}`
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
const emailSent = await sendTemplateEmail({
|
||||||
|
to: user.email,
|
||||||
|
template: 'passwordReset',
|
||||||
|
data: {
|
||||||
|
userName: user.display_name || 'User',
|
||||||
|
resetUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// If email failed to send, delete the reset request
|
||||||
|
if (!emailSent) {
|
||||||
|
await db
|
||||||
|
.deleteFrom('password_reset_requests')
|
||||||
|
.where('token', '=', token)
|
||||||
|
.execute()
|
||||||
|
console.error('Failed to send password reset email to:', user.email)
|
||||||
|
// Still return success to user to prevent enumeration
|
||||||
|
}
|
||||||
|
|
||||||
|
return successResponse
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in forgot-password endpoint:', error)
|
||||||
|
// Always return success to prevent enumeration
|
||||||
|
return successResponse
|
||||||
|
}
|
||||||
|
})
|
||||||
87
server/api/auth/login.post.ts
Normal file
87
server/api/auth/login.post.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import { db } from '../../utils/database'
|
||||||
|
import { logLoginFailed, logLogin } from '../../utils/activity-logger'
|
||||||
|
import { checkRateLimit, logRateLimitExceeded } from '../../utils/rate-limit'
|
||||||
|
import { getUserPermissions } from '../../utils/rbac'
|
||||||
|
import { readBody, getHeader, setResponseHeader, setCookie } from 'h3'
|
||||||
|
import { useRuntimeConfig, createError } from '#imports'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { email, password } = await readBody(event)
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Email and password are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit before processing login
|
||||||
|
const userAgent = getHeader(event, 'user-agent') || undefined
|
||||||
|
const rateCheck = await checkRateLimit('LOGIN_FAILED', 'email', email, 15 * 60 * 1000, 5)
|
||||||
|
|
||||||
|
if (!rateCheck.allowed) {
|
||||||
|
logRateLimitExceeded(email, '/api/auth/login', userAgent)
|
||||||
|
setResponseHeader(event, 'Retry-After', rateCheck.retryAfterSeconds!)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage: `Too many login attempts. Try again in ${Math.ceil(rateCheck.retryAfterSeconds! / 60)} minutes.`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query existing user from database
|
||||||
|
const user = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.selectAll()
|
||||||
|
.where('email', '=', email)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logLoginFailed(email, userAgent, { reason: 'user_not_found' })
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is verified
|
||||||
|
if (!user.verified) {
|
||||||
|
logLoginFailed(email, userAgent, { reason: 'not_verified' })
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Please verify your email address before logging in' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValidPassword = await bcrypt.compare(password, user.password)
|
||||||
|
if (!isValidPassword) {
|
||||||
|
logLoginFailed(email, userAgent, { reason: 'invalid_password' })
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new JWT token
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ userId: user.id, email: user.email, display_name: user.display_name },
|
||||||
|
useRuntimeConfig().jwtSecret,
|
||||||
|
{ expiresIn: '120d' }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set secure cookie
|
||||||
|
setCookie(event, 'auth-token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 24 * 120 // 120 days
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log successful login
|
||||||
|
logLogin(user.id, userAgent)
|
||||||
|
|
||||||
|
const permissions = await getUserPermissions(user.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
display_name: user.display_name,
|
||||||
|
avatar: user.avatar,
|
||||||
|
verified: user.verified,
|
||||||
|
roles: user.roles,
|
||||||
|
permissions: [...permissions]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
20
server/api/auth/logout.post.ts
Normal file
20
server/api/auth/logout.post.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// Get user info before clearing cookie
|
||||||
|
const user = getAuthUser(event)
|
||||||
|
const userAgent = getHeader(event, 'user-agent') || undefined
|
||||||
|
|
||||||
|
// Clear the auth cookie
|
||||||
|
setCookie(event, 'auth-token', '', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 0 // Expire immediately
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log logout if user was authenticated
|
||||||
|
if (user?.userId) {
|
||||||
|
logLogout(user.userId, userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
30
server/api/auth/me.get.ts
Normal file
30
server/api/auth/me.get.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { getUserPermissions } from '../../utils/rbac'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const authUser = getAuthUser(event)
|
||||||
|
|
||||||
|
if (!authUser) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Not authenticated' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get fresh user data from database
|
||||||
|
const user = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id', 'email', 'display_name', 'avatar', 'verified', 'roles', 'created', 'updated'])
|
||||||
|
.where('id', '=', authUser.userId)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'User not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = await getUserPermissions(user.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
...user,
|
||||||
|
avatar: user.avatar || null,
|
||||||
|
permissions: [...permissions]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
166
server/api/auth/register.post.ts
Normal file
166
server/api/auth/register.post.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { db, sql } from '../../utils/database'
|
||||||
|
import { logRegisterAttempt, logEvent } from '../../utils/activity-logger'
|
||||||
|
import { sendTemplateEmail } from '../../utils/email'
|
||||||
|
import { checkRateLimit, logRateLimitExceeded } from '../../utils/rate-limit'
|
||||||
|
import { readBody, getHeader, setResponseHeader, getRequestURL, setCookie } from 'h3'
|
||||||
|
import { useRuntimeConfig, createError } from '#imports'
|
||||||
|
import { ROLES } from '~~/app/utils/role-definitions'
|
||||||
|
|
||||||
|
// Advisory-lock key used to serialize concurrent registrations so the
|
||||||
|
// first-user count check and insert can't interleave (ASCII 'reg1').
|
||||||
|
const REGISTRATION_LOCK_KEY = 1919248689
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { email, password, display_name } = await readBody(event)
|
||||||
|
|
||||||
|
if (!email || !password || !display_name) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Email, password, and display name are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (display_name.length < 2) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Display name must be at least 2 characters long' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit by IP
|
||||||
|
const clientIp = getHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
|
||||||
|
const userAgent = getHeader(event, 'user-agent') || undefined
|
||||||
|
const rateCheck = await checkRateLimit('REGISTER_ATTEMPT', 'ip', clientIp, 15 * 60 * 1000, 10)
|
||||||
|
|
||||||
|
if (!rateCheck.allowed) {
|
||||||
|
logRateLimitExceeded(clientIp, '/api/auth/register', userAgent)
|
||||||
|
setResponseHeader(event, 'Retry-After', rateCheck.retryAfterSeconds!)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage: 'Too many registration attempts. Please try again later.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log this registration attempt
|
||||||
|
logRegisterAttempt(clientIp, userAgent)
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12)
|
||||||
|
|
||||||
|
// Generate unique ID and token key
|
||||||
|
const userId = randomUUID()
|
||||||
|
const tokenKey = randomUUID()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
// First-user check + insert run atomically inside a transaction, with a
|
||||||
|
// session-level advisory lock to serialize concurrent registrations. The
|
||||||
|
// activity-log insert also rides on this transaction so the "who got the
|
||||||
|
// admin role" audit row commits with the user row.
|
||||||
|
const { isFirstUser } = await db.transaction().execute(async (trx) => {
|
||||||
|
await sql`SELECT pg_advisory_xact_lock(${sql.lit(REGISTRATION_LOCK_KEY)})`.execute(trx)
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await trx
|
||||||
|
.selectFrom('users')
|
||||||
|
.select('id')
|
||||||
|
.where('email', '=', email)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'User with this email already exists' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const countRow = await trx
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(eb => eb.fn.count<string>('id').as('count'))
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
const firstUser = Number(countRow?.count ?? 0) === 0
|
||||||
|
|
||||||
|
// Create user (first user is auto-promoted; everyone else starts unverified)
|
||||||
|
await trx
|
||||||
|
.insertInto('users')
|
||||||
|
.values({
|
||||||
|
id: userId,
|
||||||
|
created: now,
|
||||||
|
updated: now,
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
verified: firstUser,
|
||||||
|
roles: firstUser ? ['admin'] : ['member'],
|
||||||
|
display_name,
|
||||||
|
avatar: '',
|
||||||
|
token_key: tokenKey,
|
||||||
|
})
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
if (firstUser) {
|
||||||
|
await logEvent({
|
||||||
|
eventType: 'first_user_promoted',
|
||||||
|
userId,
|
||||||
|
userAgent,
|
||||||
|
metadata: { email }
|
||||||
|
}, trx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isFirstUser: firstUser }
|
||||||
|
})
|
||||||
|
|
||||||
|
// First user gets auto-logged-in — no verification email, JWT cookie set directly.
|
||||||
|
if (isFirstUser) {
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ userId, email, display_name },
|
||||||
|
useRuntimeConfig().jwtSecret,
|
||||||
|
{ expiresIn: '120d' }
|
||||||
|
)
|
||||||
|
|
||||||
|
setCookie(event, 'auth-token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 60 * 60 * 24 * 120
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
autoLoggedIn: true,
|
||||||
|
requiresVerification: false,
|
||||||
|
user: {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
avatar: '',
|
||||||
|
verified: true,
|
||||||
|
roles: ['admin'],
|
||||||
|
permissions: [...ROLES.admin.permissions]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal registration: send verification email. Any failure is logged and
|
||||||
|
// swallowed — the account is already committed, so turning an email failure
|
||||||
|
// into a 500 would trap the user with an account they can't re-register.
|
||||||
|
const baseUrl = getRequestURL(event).origin
|
||||||
|
const verificationUrl = `${baseUrl}/api/auth/verify?token=${tokenKey}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emailSent = await sendTemplateEmail({
|
||||||
|
to: email,
|
||||||
|
template: 'verification',
|
||||||
|
data: {
|
||||||
|
userName: display_name,
|
||||||
|
verificationUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!emailSent) {
|
||||||
|
console.error('Failed to send verification email to:', email)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error sending verification email:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
autoLoggedIn: false,
|
||||||
|
requiresVerification: true,
|
||||||
|
message: 'Registration successful! Please check your email to verify your account.'
|
||||||
|
}
|
||||||
|
})
|
||||||
101
server/api/auth/reset-password.post.ts
Normal file
101
server/api/auth/reset-password.post.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { token, password, confirmPassword } = await readBody(event)
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if (!token || !password || !confirmPassword) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Token, password, and confirmation are required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate passwords match
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Passwords do not match'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password length
|
||||||
|
if (password.length < 6) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Password must be at least 6 characters'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize token
|
||||||
|
const sanitizedToken = token.trim().substring(0, 255)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find valid reset request
|
||||||
|
const resetRequest = await db
|
||||||
|
.selectFrom('password_reset_requests')
|
||||||
|
.selectAll()
|
||||||
|
.where('token', '=', sanitizedToken)
|
||||||
|
.where('used', '=', false)
|
||||||
|
.where('expires', '>', new Date().toISOString())
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!resetRequest) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Invalid or expired reset token'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the new password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12)
|
||||||
|
|
||||||
|
await db.transaction().execute(async (trx) => {
|
||||||
|
// Update user's password
|
||||||
|
await trx
|
||||||
|
.updateTable('users')
|
||||||
|
.set({ password: hashedPassword })
|
||||||
|
.where('id', '=', resetRequest.user_id)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
// Mark token as used
|
||||||
|
await trx
|
||||||
|
.updateTable('password_reset_requests')
|
||||||
|
.set({ used: true })
|
||||||
|
.where('token', '=', sanitizedToken)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
// Clean up old/expired reset requests
|
||||||
|
await trx
|
||||||
|
.deleteFrom('password_reset_requests')
|
||||||
|
.where('user_id', '=', resetRequest.user_id)
|
||||||
|
.where((eb) =>
|
||||||
|
eb.or([
|
||||||
|
eb('used', '=', true),
|
||||||
|
eb('expires', '<=', new Date().toISOString()),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log the password reset
|
||||||
|
const userAgent = getHeader(event, 'user-agent') || undefined
|
||||||
|
logPasswordReset(resetRequest.user_id, userAgent)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Password reset successfully. You can now log in with your new password.'
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// If it's already a createError, throw it as-is
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Error in reset-password endpoint:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'An error occurred while resetting your password'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
88
server/api/auth/verify-email-change.get.ts
Normal file
88
server/api/auth/verify-email-change.get.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { sql } from 'kysely'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const token = query.token as string
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Verification token is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find user with this email change token
|
||||||
|
const user = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id', 'email', 'pending_email', 'email_change_token'])
|
||||||
|
.where('email_change_token', '=', token)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Redirect to profile with error message
|
||||||
|
return sendRedirect(event, '/profile?email_change=invalid_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.pending_email) {
|
||||||
|
// No pending email change
|
||||||
|
return sendRedirect(event, '/profile?email_change=no_pending')
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldEmail = user.email
|
||||||
|
const newEmail = user.pending_email
|
||||||
|
|
||||||
|
// Check if new email is now taken by someone else (race condition check)
|
||||||
|
const existingUser = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select('id')
|
||||||
|
.where('email', '=', newEmail)
|
||||||
|
.where('id', '!=', user.id)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
// Clean up pending change
|
||||||
|
await db
|
||||||
|
.updateTable('users')
|
||||||
|
.set({ pending_email: null, email_change_token: null })
|
||||||
|
.where('id', '=', user.id)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
return sendRedirect(event, '/profile?email_change=email_taken')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update email and clear pending fields
|
||||||
|
await db
|
||||||
|
.updateTable('users')
|
||||||
|
.set({
|
||||||
|
email: newEmail,
|
||||||
|
pending_email: null,
|
||||||
|
email_change_token: null,
|
||||||
|
updated: sql`now()`,
|
||||||
|
})
|
||||||
|
.where('id', '=', user.id)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
// Log successful email change
|
||||||
|
logEvent({
|
||||||
|
eventType: 'EMAIL_CHANGED',
|
||||||
|
tableName: 'users',
|
||||||
|
recordId: user.id,
|
||||||
|
userId: user.id,
|
||||||
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
|
metadata: {
|
||||||
|
old_email: oldEmail,
|
||||||
|
new_email: newEmail
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redirect to profile with success message
|
||||||
|
return sendRedirect(event, '/profile?email_change=success')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Email change verification error:', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Email change verification failed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
39
server/api/auth/verify.get.ts
Normal file
39
server/api/auth/verify.get.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const query = getQuery(event)
|
||||||
|
const token = query.token as string
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Verification token is required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find user with this token
|
||||||
|
const user = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id', 'verified'])
|
||||||
|
.where('token_key', '=', token)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Invalid verification token' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.verified) {
|
||||||
|
// Already verified, redirect to login with message
|
||||||
|
return sendRedirect(event, '/login?verified=already')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark user as verified
|
||||||
|
await db
|
||||||
|
.updateTable('users')
|
||||||
|
.set({ verified: true, updated: new Date().toISOString() })
|
||||||
|
.where('id', '=', user.id)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
// Redirect to login with success message
|
||||||
|
return sendRedirect(event, '/login?verified=success')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Email verification error:', error)
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'Email verification failed' })
|
||||||
|
}
|
||||||
|
})
|
||||||
94
server/api/profile/account.delete.ts
Normal file
94
server/api/profile/account.delete.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// Require authentication
|
||||||
|
const user = requireAuth(event)
|
||||||
|
|
||||||
|
// Get request body
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { password } = body
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!password || typeof password !== 'string') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Password is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user password hash
|
||||||
|
const current = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['password', 'email'])
|
||||||
|
.where('id', '=', user.userId)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'User not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, current.password)
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
// Log failed account deletion attempt
|
||||||
|
logEvent({
|
||||||
|
eventType: 'ACCOUNT_DELETE_FAILED',
|
||||||
|
tableName: 'users',
|
||||||
|
recordId: user.userId,
|
||||||
|
userId: user.userId,
|
||||||
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
|
metadata: {
|
||||||
|
reason: 'invalid_password'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Password is incorrect'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete related data in correct order (foreign key constraints)
|
||||||
|
await db.transaction().execute(async (trx) => {
|
||||||
|
// 1. Delete password reset requests
|
||||||
|
await trx
|
||||||
|
.deleteFrom('password_reset_requests')
|
||||||
|
.where('user_id', '=', user.userId)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
// 2. Delete activity logs for this user
|
||||||
|
await trx
|
||||||
|
.deleteFrom('activity_logs')
|
||||||
|
.where('user_id', '=', user.userId)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
// 3. Delete the user
|
||||||
|
await trx
|
||||||
|
.deleteFrom('users')
|
||||||
|
.where('id', '=', user.userId)
|
||||||
|
.execute()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log account deletion (this log entry won't have user_id since we deleted it)
|
||||||
|
logEvent({
|
||||||
|
eventType: 'ACCOUNT_DELETED',
|
||||||
|
tableName: 'users',
|
||||||
|
recordId: user.userId,
|
||||||
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
|
metadata: {
|
||||||
|
email: current.email
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear auth cookie
|
||||||
|
deleteCookie(event, 'auth-token')
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Account deleted successfully'
|
||||||
|
}
|
||||||
|
})
|
||||||
163
server/api/profile/email.post.ts
Normal file
163
server/api/profile/email.post.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { sql } from 'kysely'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// Require authentication
|
||||||
|
const user = requireAuth(event)
|
||||||
|
|
||||||
|
// Get request body
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { new_email, current_password } = body
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!new_email || typeof new_email !== 'string') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'New email is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current_password || typeof current_password !== 'string') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Current password is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedEmail = new_email.trim().toLowerCase()
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(trimmedEmail)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Invalid email format'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user data including password
|
||||||
|
const currentUser = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['email', 'password'])
|
||||||
|
.where('id', '=', user.userId)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'User not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if new email is same as current
|
||||||
|
if (trimmedEmail === currentUser.email) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'New email must be different from current email'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isPasswordValid = await bcrypt.compare(current_password, currentUser.password)
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
// Log failed attempt
|
||||||
|
logEvent({
|
||||||
|
eventType: 'EMAIL_CHANGE_FAILED',
|
||||||
|
tableName: 'users',
|
||||||
|
recordId: user.userId,
|
||||||
|
userId: user.userId,
|
||||||
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
|
metadata: {
|
||||||
|
reason: 'invalid_password',
|
||||||
|
attempted_new_email: trimmedEmail
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Current password is incorrect'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if new email is already taken by another user
|
||||||
|
const existingUser = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select('id')
|
||||||
|
.where('email', '=', trimmedEmail)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'This email address is already in use'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate verification token
|
||||||
|
const emailChangeToken = crypto.randomUUID()
|
||||||
|
|
||||||
|
// Store pending email and token
|
||||||
|
await db
|
||||||
|
.updateTable('users')
|
||||||
|
.set({
|
||||||
|
pending_email: trimmedEmail,
|
||||||
|
email_change_token: emailChangeToken,
|
||||||
|
updated: sql`now()`,
|
||||||
|
})
|
||||||
|
.where('id', '=', user.userId)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
// Get runtime config for email
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
// Send verification email to NEW email address
|
||||||
|
try {
|
||||||
|
const verificationUrl = `${process.env.APP_URL || 'http://localhost:3002'}/api/auth/verify-email-change?token=${emailChangeToken}`
|
||||||
|
|
||||||
|
await sendTemplateEmail({
|
||||||
|
to: trimmedEmail,
|
||||||
|
subject: 'Verify Your New Email Address',
|
||||||
|
template: 'emailChangeVerification',
|
||||||
|
data: {
|
||||||
|
userName: user.display_name,
|
||||||
|
oldEmail: currentUser.email,
|
||||||
|
newEmail: trimmedEmail,
|
||||||
|
verificationUrl: verificationUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log successful email change request
|
||||||
|
logEvent({
|
||||||
|
eventType: 'EMAIL_CHANGE_REQUESTED',
|
||||||
|
tableName: 'users',
|
||||||
|
recordId: user.userId,
|
||||||
|
userId: user.userId,
|
||||||
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
|
metadata: {
|
||||||
|
old_email: currentUser.email,
|
||||||
|
new_email: trimmedEmail
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Verification email sent to new address'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending verification email:', error)
|
||||||
|
|
||||||
|
// Clean up pending email change on error
|
||||||
|
await db
|
||||||
|
.updateTable('users')
|
||||||
|
.set({ pending_email: null, email_change_token: null })
|
||||||
|
.where('id', '=', user.userId)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to send verification email'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
67
server/api/profile/name.patch.ts
Normal file
67
server/api/profile/name.patch.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { sql } from 'kysely'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// Require authentication
|
||||||
|
const user = requireAuth(event)
|
||||||
|
|
||||||
|
// Get request body
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { display_name } = body
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!display_name || typeof display_name !== 'string') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Display name is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedName = display_name.trim()
|
||||||
|
|
||||||
|
if (trimmedName.length === 0) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Display name cannot be empty'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedName.length > 100) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Display name is too long (max 100 characters)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update display name in database
|
||||||
|
const updated = await db
|
||||||
|
.updateTable('users')
|
||||||
|
.set({ display_name: trimmedName, updated: sql`now()` })
|
||||||
|
.where('id', '=', user.userId)
|
||||||
|
.returning(['id', 'email', 'display_name'])
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'User not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
logEvent({
|
||||||
|
eventType: 'PROFILE_NAME_UPDATED',
|
||||||
|
tableName: 'users',
|
||||||
|
recordId: user.userId,
|
||||||
|
userId: user.userId,
|
||||||
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
|
metadata: {
|
||||||
|
old_name: user.display_name,
|
||||||
|
new_name: trimmedName
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: updated
|
||||||
|
}
|
||||||
|
})
|
||||||
110
server/api/profile/password.patch.ts
Normal file
110
server/api/profile/password.patch.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import { sql } from 'kysely'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// Require authentication
|
||||||
|
const user = requireAuth(event)
|
||||||
|
|
||||||
|
// Get request body
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { current_password, new_password } = body
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
if (!current_password || typeof current_password !== 'string') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Current password is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!new_password || typeof new_password !== 'string') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'New password is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new_password.length < 8) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'New password must be at least 8 characters long'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new_password.length > 128) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'New password is too long (max 128 characters)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow same password
|
||||||
|
if (current_password === new_password) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'New password must be different from current password'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user password hash
|
||||||
|
const current = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select('password')
|
||||||
|
.where('id', '=', user.userId)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'User not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isCurrentPasswordValid = await bcrypt.compare(current_password, current.password)
|
||||||
|
|
||||||
|
if (!isCurrentPasswordValid) {
|
||||||
|
// Log failed password change attempt
|
||||||
|
logEvent({
|
||||||
|
eventType: 'PASSWORD_CHANGE_FAILED',
|
||||||
|
tableName: 'users',
|
||||||
|
recordId: user.userId,
|
||||||
|
userId: user.userId,
|
||||||
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
|
metadata: {
|
||||||
|
reason: 'invalid_current_password'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Current password is incorrect'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
const saltRounds = 12
|
||||||
|
const newPasswordHash = await bcrypt.hash(new_password, saltRounds)
|
||||||
|
|
||||||
|
// Update password in database
|
||||||
|
await db
|
||||||
|
.updateTable('users')
|
||||||
|
.set({ password: newPasswordHash, updated: sql`now()` })
|
||||||
|
.where('id', '=', user.userId)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
// Log successful password change
|
||||||
|
logEvent({
|
||||||
|
eventType: 'PASSWORD_CHANGED',
|
||||||
|
tableName: 'users',
|
||||||
|
recordId: user.userId,
|
||||||
|
userId: user.userId,
|
||||||
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
|
metadata: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Password changed successfully'
|
||||||
|
}
|
||||||
|
})
|
||||||
42
server/database/schema.ts
Normal file
42
server/database/schema.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type { ColumnType, Generated } from 'kysely'
|
||||||
|
|
||||||
|
export interface UsersTable {
|
||||||
|
id: Generated<string>
|
||||||
|
created: ColumnType<Date, string | undefined, string>
|
||||||
|
updated: ColumnType<Date, string | undefined, string>
|
||||||
|
email: string
|
||||||
|
display_name: string
|
||||||
|
avatar: Generated<string>
|
||||||
|
password: Generated<string>
|
||||||
|
verified: Generated<boolean>
|
||||||
|
roles: Generated<string[]>
|
||||||
|
token_key: Generated<string>
|
||||||
|
pending_email: string | null
|
||||||
|
email_change_token: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetRequestsTable {
|
||||||
|
id: Generated<string>
|
||||||
|
created: ColumnType<Date, Date | string | undefined, Date | string>
|
||||||
|
expires: ColumnType<Date, Date | string, Date | string>
|
||||||
|
user_id: string
|
||||||
|
token: string
|
||||||
|
used: Generated<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityLogsTable {
|
||||||
|
id: Generated<string>
|
||||||
|
timestamp: ColumnType<Date, Date | string | undefined, Date | string>
|
||||||
|
event_type: string
|
||||||
|
table_name: string | null
|
||||||
|
record_id: string | null
|
||||||
|
user_id: string | null
|
||||||
|
user_agent: string | null
|
||||||
|
metadata: Generated<Record<string, any>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Database {
|
||||||
|
users: UsersTable
|
||||||
|
password_reset_requests: PasswordResetRequestsTable
|
||||||
|
activity_logs: ActivityLogsTable
|
||||||
|
}
|
||||||
49
server/plugins/migrations.ts
Normal file
49
server/plugins/migrations.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { FileMigrationProvider, Migrator } from 'kysely'
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import { getDb } from '../utils/database'
|
||||||
|
|
||||||
|
export default defineNitroPlugin(async () => {
|
||||||
|
const databaseUrl = useRuntimeConfig().databaseUrl || process.env.DATABASE_URL
|
||||||
|
if (!databaseUrl) {
|
||||||
|
console.warn('DATABASE_URL not set, skipping migrations')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrator = new Migrator({
|
||||||
|
db: getDb(),
|
||||||
|
provider: new FileMigrationProvider({
|
||||||
|
fs,
|
||||||
|
path,
|
||||||
|
migrationFolder: path.join(process.cwd(), 'migrations'),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Identify pending migrations so we can announce them before execution
|
||||||
|
const all = await migrator.getMigrations()
|
||||||
|
const pending = all.filter(m => !m.executedAt)
|
||||||
|
|
||||||
|
if (pending.length === 0) {
|
||||||
|
console.log('Migrations already up-to-date')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Running ${pending.length} pending migration(s)...`)
|
||||||
|
for (const m of pending) {
|
||||||
|
console.log(` Migration: ${m.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error, results } = await migrator.migrateToLatest()
|
||||||
|
|
||||||
|
results?.forEach(r => {
|
||||||
|
if (r.status === 'Success') console.log(`✓ ${r.migrationName}`)
|
||||||
|
if (r.status === 'Error') console.error(`✗ ${r.migrationName}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Migration failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Migrations complete')
|
||||||
|
})
|
||||||
265
server/utils/activity-logger.ts
Normal file
265
server/utils/activity-logger.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
import { db } from './database'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import type { H3Event } from 'h3'
|
||||||
|
import type { Kysely } from 'kysely'
|
||||||
|
import type { Database } from '~/server/database/schema'
|
||||||
|
import { getAuthUser } from './auth'
|
||||||
|
|
||||||
|
interface LogEventOptions {
|
||||||
|
eventType: string
|
||||||
|
tableName?: string
|
||||||
|
recordId?: string
|
||||||
|
userId?: string
|
||||||
|
userAgent?: string
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core function to log an activity event.
|
||||||
|
*
|
||||||
|
* Accepts an optional `executor` so the insert can run inside a caller's
|
||||||
|
* transaction — useful when the audit row must commit atomically with the
|
||||||
|
* operation it describes (e.g. granting the first user the admin role).
|
||||||
|
* Defaults to the global db for fire-and-forget use from helpers.
|
||||||
|
*/
|
||||||
|
export async function logEvent(
|
||||||
|
options: LogEventOptions,
|
||||||
|
executor: Kysely<Database> = db
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await executor
|
||||||
|
.insertInto('activity_logs')
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
event_type: options.eventType,
|
||||||
|
table_name: options.tableName ?? null,
|
||||||
|
record_id: options.recordId ?? null,
|
||||||
|
user_id: options.userId ?? null,
|
||||||
|
user_agent: options.userAgent ?? null,
|
||||||
|
metadata: options.metadata ?? {},
|
||||||
|
})
|
||||||
|
.execute()
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't throw - logging should never break the main flow
|
||||||
|
console.error('Failed to log activity:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to extract user info from H3Event
|
||||||
|
*/
|
||||||
|
function getUserInfoFromEvent(event: H3Event | null): { userId?: string; userAgent?: string } {
|
||||||
|
if (!event) return {}
|
||||||
|
|
||||||
|
const user = getAuthUser(event)
|
||||||
|
const userAgent = getHeader(event, 'user-agent') || undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user?.userId,
|
||||||
|
userAgent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a CREATE event
|
||||||
|
*/
|
||||||
|
export function logCreate(
|
||||||
|
tableName: string,
|
||||||
|
recordId: string,
|
||||||
|
userIdOrEvent?: string | H3Event,
|
||||||
|
metadata?: any
|
||||||
|
): void {
|
||||||
|
let userId: string | undefined
|
||||||
|
let userAgent: string | undefined
|
||||||
|
|
||||||
|
if (typeof userIdOrEvent === 'string') {
|
||||||
|
userId = userIdOrEvent
|
||||||
|
} else if (userIdOrEvent) {
|
||||||
|
const info = getUserInfoFromEvent(userIdOrEvent)
|
||||||
|
userId = info.userId
|
||||||
|
userAgent = info.userAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
eventType: 'CREATE',
|
||||||
|
tableName,
|
||||||
|
recordId,
|
||||||
|
userId,
|
||||||
|
userAgent,
|
||||||
|
metadata
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an UPDATE event
|
||||||
|
*/
|
||||||
|
export function logUpdate(
|
||||||
|
tableName: string,
|
||||||
|
recordId: string,
|
||||||
|
userIdOrEvent?: string | H3Event,
|
||||||
|
metadata?: any
|
||||||
|
): void {
|
||||||
|
let userId: string | undefined
|
||||||
|
let userAgent: string | undefined
|
||||||
|
|
||||||
|
if (typeof userIdOrEvent === 'string') {
|
||||||
|
userId = userIdOrEvent
|
||||||
|
} else if (userIdOrEvent) {
|
||||||
|
const info = getUserInfoFromEvent(userIdOrEvent)
|
||||||
|
userId = info.userId
|
||||||
|
userAgent = info.userAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
eventType: 'UPDATE',
|
||||||
|
tableName,
|
||||||
|
recordId,
|
||||||
|
userId,
|
||||||
|
userAgent,
|
||||||
|
metadata
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a DELETE event
|
||||||
|
*/
|
||||||
|
export function logDelete(
|
||||||
|
tableName: string,
|
||||||
|
recordId: string,
|
||||||
|
userIdOrEvent?: string | H3Event,
|
||||||
|
metadata?: any
|
||||||
|
): void {
|
||||||
|
let userId: string | undefined
|
||||||
|
let userAgent: string | undefined
|
||||||
|
|
||||||
|
if (typeof userIdOrEvent === 'string') {
|
||||||
|
userId = userIdOrEvent
|
||||||
|
} else if (userIdOrEvent) {
|
||||||
|
const info = getUserInfoFromEvent(userIdOrEvent)
|
||||||
|
userId = info.userId
|
||||||
|
userAgent = info.userAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
eventType: 'DELETE',
|
||||||
|
tableName,
|
||||||
|
recordId,
|
||||||
|
userId,
|
||||||
|
userAgent,
|
||||||
|
metadata
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a successful LOGIN event
|
||||||
|
*/
|
||||||
|
export function logLogin(
|
||||||
|
userId: string,
|
||||||
|
userAgent?: string,
|
||||||
|
metadata?: any
|
||||||
|
): void {
|
||||||
|
logEvent({
|
||||||
|
eventType: 'LOGIN',
|
||||||
|
userId,
|
||||||
|
userAgent,
|
||||||
|
metadata
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a failed LOGIN attempt
|
||||||
|
* Note: email must be stored in metadata.email for privacy
|
||||||
|
*/
|
||||||
|
export function logLoginFailed(
|
||||||
|
email: string,
|
||||||
|
userAgent?: string,
|
||||||
|
metadata?: any
|
||||||
|
): void {
|
||||||
|
logEvent({
|
||||||
|
eventType: 'LOGIN_FAILED',
|
||||||
|
userAgent,
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
email
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a LOGOUT event
|
||||||
|
*/
|
||||||
|
export function logLogout(
|
||||||
|
userId: string,
|
||||||
|
userAgent?: string,
|
||||||
|
metadata?: any
|
||||||
|
): void {
|
||||||
|
logEvent({
|
||||||
|
eventType: 'LOGOUT',
|
||||||
|
userId,
|
||||||
|
userAgent,
|
||||||
|
metadata
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a PASSWORD_RESET event
|
||||||
|
*/
|
||||||
|
export function logPasswordReset(
|
||||||
|
userId: string,
|
||||||
|
userAgent?: string,
|
||||||
|
metadata?: any
|
||||||
|
): void {
|
||||||
|
logEvent({
|
||||||
|
eventType: 'PASSWORD_RESET',
|
||||||
|
userId,
|
||||||
|
userAgent,
|
||||||
|
metadata
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an EMAIL_CHANGE event
|
||||||
|
*/
|
||||||
|
export function logEmailChange(
|
||||||
|
userId: string,
|
||||||
|
userAgent?: string,
|
||||||
|
metadata?: any
|
||||||
|
): void {
|
||||||
|
logEvent({
|
||||||
|
eventType: 'EMAIL_CHANGE',
|
||||||
|
userId,
|
||||||
|
userAgent,
|
||||||
|
metadata
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a registration attempt (for rate limiting by IP)
|
||||||
|
*/
|
||||||
|
export function logRegisterAttempt(
|
||||||
|
ip: string,
|
||||||
|
userAgent?: string,
|
||||||
|
metadata?: any
|
||||||
|
): void {
|
||||||
|
logEvent({
|
||||||
|
eventType: 'REGISTER_ATTEMPT',
|
||||||
|
userAgent,
|
||||||
|
metadata: { ...metadata, ip }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a password reset request (for rate limiting by email)
|
||||||
|
*/
|
||||||
|
export function logPasswordResetRequest(
|
||||||
|
email: string,
|
||||||
|
userAgent?: string,
|
||||||
|
metadata?: any
|
||||||
|
): void {
|
||||||
|
logEvent({
|
||||||
|
eventType: 'PASSWORD_RESET_REQUEST',
|
||||||
|
userAgent,
|
||||||
|
metadata: { ...metadata, email }
|
||||||
|
})
|
||||||
|
}
|
||||||
26
server/utils/auth.ts
Normal file
26
server/utils/auth.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import type { H3Event } from 'h3'
|
||||||
|
|
||||||
|
export function verifyToken(token: string) {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, useRuntimeConfig().jwtSecret) as { userId: string; email: string; display_name: string }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAuth(event: H3Event) {
|
||||||
|
const token = getCookie(event, 'auth-token')
|
||||||
|
const user = token ? verifyToken(token) : null
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthUser(event: H3Event) {
|
||||||
|
const token = getCookie(event, 'auth-token')
|
||||||
|
return token ? verifyToken(token) : null
|
||||||
|
}
|
||||||
39
server/utils/database.ts
Normal file
39
server/utils/database.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Kysely, sql } from 'kysely'
|
||||||
|
import { PostgresJSDialect } from 'kysely-postgres-js'
|
||||||
|
import postgres from 'postgres'
|
||||||
|
import type { Database } from '~/server/database/schema'
|
||||||
|
|
||||||
|
let _db: Kysely<Database> | null = null
|
||||||
|
|
||||||
|
function getDb(): Kysely<Database> {
|
||||||
|
if (_db) return _db
|
||||||
|
|
||||||
|
const databaseUrl = useRuntimeConfig().databaseUrl || process.env.DATABASE_URL
|
||||||
|
if (!databaseUrl) throw new Error('DATABASE_URL is not set')
|
||||||
|
|
||||||
|
const isLocal = databaseUrl.includes('localhost') || databaseUrl.includes('127.0.0.1')
|
||||||
|
|
||||||
|
_db = new Kysely<Database>({
|
||||||
|
dialect: new PostgresJSDialect({
|
||||||
|
postgres: postgres(databaseUrl, {
|
||||||
|
ssl: isLocal ? false : 'require',
|
||||||
|
max: 10,
|
||||||
|
idle_timeout: 20,
|
||||||
|
connect_timeout: 30,
|
||||||
|
onnotice: () => {},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return _db
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = new Proxy({} as Kysely<Database>, {
|
||||||
|
get: (_, prop) => {
|
||||||
|
const real = getDb()
|
||||||
|
const value = (real as any)[prop]
|
||||||
|
return typeof value === 'function' ? value.bind(real) : value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export { getDb, sql }
|
||||||
226
server/utils/email-templates.ts
Normal file
226
server/utils/email-templates.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
export interface EmailTemplateData {
|
||||||
|
appName?: string
|
||||||
|
userName?: string
|
||||||
|
userEmail?: string
|
||||||
|
timestamp?: string
|
||||||
|
environment?: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailTemplate {
|
||||||
|
subject: string
|
||||||
|
html: (data: EmailTemplateData) => string
|
||||||
|
text: (data: EmailTemplateData) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseStyles = {
|
||||||
|
container: 'font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;',
|
||||||
|
header: 'background: linear-gradient(135deg, #000000 0%, #374151 100%); color: white; padding: 30px; border-radius: 10px 10px 0 0; text-align: center;',
|
||||||
|
content: 'background: white; padding: 30px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 10px 10px;',
|
||||||
|
footer: 'text-align: center; margin-top: 20px; color: #9ca3af; font-size: 12px;',
|
||||||
|
infoBox: 'background: #f3f4f6; border-left: 4px solid #000000; padding: 15px; margin: 20px 0; border-radius: 0 5px 5px 0;',
|
||||||
|
button: 'background: #000000; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmailTemplate(
|
||||||
|
subject: string,
|
||||||
|
htmlContent: (data: EmailTemplateData) => string,
|
||||||
|
textContent: (data: EmailTemplateData) => string
|
||||||
|
): EmailTemplate {
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
html: (data: EmailTemplateData) => `
|
||||||
|
<div style="${baseStyles.container}">
|
||||||
|
<div style="${baseStyles.header}">
|
||||||
|
<h1 style="margin: 0; font-size: 24px;">${data.appName || 'App'}</h1>
|
||||||
|
<p style="margin: 10px 0 0 0; opacity: 0.9;">${subject}</p>
|
||||||
|
</div>
|
||||||
|
<div style="${baseStyles.content}">
|
||||||
|
${htmlContent(data)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
text: textContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const testEmailTemplate: EmailTemplate = buildEmailTemplate(
|
||||||
|
'Test Email',
|
||||||
|
(data) => `
|
||||||
|
<h2 style="color: #333; margin-top: 0;">Hello ${data.userName || 'User'}!</h2>
|
||||||
|
<p style="color: #666; line-height: 1.6;">
|
||||||
|
This is a test email from ${data.appName || 'the application'}. If you're receiving this,
|
||||||
|
the email system is working correctly.
|
||||||
|
</p>
|
||||||
|
<div style="${baseStyles.infoBox}">
|
||||||
|
<h3 style="margin: 0 0 10px 0; color: #333;">Email Details:</h3>
|
||||||
|
<ul style="margin: 0; color: #666;">
|
||||||
|
<li><strong>Sent to:</strong> ${data.userEmail || 'Unknown'}</li>
|
||||||
|
<li><strong>Timestamp:</strong> ${data.timestamp || 'Unknown'}</li>
|
||||||
|
<li><strong>Environment:</strong> ${data.environment || 'Unknown'}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
(data) => `Test Email from ${data.appName || 'App'}\n\nHello ${data.userName || 'User'}!\n\nThis is a test email. The email system is working correctly.\n\nSent to: ${data.userEmail || 'Unknown'}\nTimestamp: ${data.timestamp || 'Unknown'}\nEnvironment: ${data.environment || 'Unknown'}`
|
||||||
|
)
|
||||||
|
|
||||||
|
export const welcomeEmailTemplate: EmailTemplate = buildEmailTemplate(
|
||||||
|
'Welcome!',
|
||||||
|
(data) => `
|
||||||
|
<h2 style="color: #333; margin-top: 0;">Welcome ${data.userName || 'User'}!</h2>
|
||||||
|
<p style="color: #666; line-height: 1.6;">
|
||||||
|
Thank you for joining ${data.appName || 'us'}! We're excited to have you on board.
|
||||||
|
</p>
|
||||||
|
<p style="color: #666; line-height: 1.6;">
|
||||||
|
If you have any questions or run into any issues, don't hesitate to reach out.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
(data) => `Welcome to ${data.appName || 'App'}!\n\nHello ${data.userName || 'User'}!\n\nThank you for joining. We're excited to have you on board.`
|
||||||
|
)
|
||||||
|
|
||||||
|
export const notificationEmailTemplate: EmailTemplate = buildEmailTemplate(
|
||||||
|
'New Notification',
|
||||||
|
(data) => `
|
||||||
|
<h2 style="color: #333; margin-top: 0;">Hello ${data.userName || 'User'}!</h2>
|
||||||
|
<p style="color: #666; line-height: 1.6;">
|
||||||
|
You have a new notification from ${data.appName || 'the application'}.
|
||||||
|
</p>
|
||||||
|
<div style="${baseStyles.infoBox}">
|
||||||
|
<p style="margin: 0; color: #666;">${data.message || 'No message provided'}</p>
|
||||||
|
</div>
|
||||||
|
${data.actionUrl ? `
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<a href="${data.actionUrl}" style="${baseStyles.button}">
|
||||||
|
${data.actionText || 'View Details'}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`,
|
||||||
|
(data) => `New Notification from ${data.appName || 'App'}\n\nHello ${data.userName || 'User'}!\n\n${data.message || 'No message provided'}\n\n${data.actionUrl ? `${data.actionText || 'View Details'}: ${data.actionUrl}` : ''}`
|
||||||
|
)
|
||||||
|
|
||||||
|
export const bulkEmailTemplate: EmailTemplate = buildEmailTemplate(
|
||||||
|
'Admin Message',
|
||||||
|
(data) => `
|
||||||
|
<h2 style="color: #333; margin-top: 0;">Hello ${data.userName || 'User'}!</h2>
|
||||||
|
<div style="color: #666; line-height: 1.6;">
|
||||||
|
${data.message || 'No message provided'}
|
||||||
|
</div>
|
||||||
|
${data.actionUrl ? `
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<a href="${data.actionUrl}" style="${baseStyles.button}">
|
||||||
|
${data.actionText || 'Learn More'}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`,
|
||||||
|
(data) => `Admin Message from ${data.appName || 'App'}\n\nHello ${data.userName || 'User'}!\n\n${data.message || 'No message provided'}`
|
||||||
|
)
|
||||||
|
|
||||||
|
export const verificationEmailTemplate: EmailTemplate = buildEmailTemplate(
|
||||||
|
'Verify Your Email Address',
|
||||||
|
(data) => `
|
||||||
|
<h2 style="color: #333; margin-top: 0;">Hello ${data.userName || 'User'}!</h2>
|
||||||
|
<p style="color: #666; line-height: 1.6;">
|
||||||
|
Thank you for registering with ${data.appName || 'us'}! To complete your registration,
|
||||||
|
please verify your email address by clicking the button below.
|
||||||
|
</p>
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<a href="${data.verificationUrl || '#'}" style="${baseStyles.button}">
|
||||||
|
Verify Email Address
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="color: #666; line-height: 1.6; margin-top: 20px; font-size: 14px;">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:
|
||||||
|
<br>
|
||||||
|
<a href="${data.verificationUrl || '#'}" style="color: #000000; text-decoration: underline; word-break: break-all;">
|
||||||
|
${data.verificationUrl || '#'}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color: #666; line-height: 1.6; margin-top: 20px; font-size: 14px;">
|
||||||
|
This link will expire in 24 hours. If you didn't create an account,
|
||||||
|
you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
(data) => `Verify Your Email Address - ${data.appName || 'App'}\n\nHello ${data.userName || 'User'}!\n\nPlease verify your email by visiting:\n\n${data.verificationUrl || '#'}\n\nThis link will expire in 24 hours.`
|
||||||
|
)
|
||||||
|
|
||||||
|
export const emailChangeVerificationTemplate: EmailTemplate = buildEmailTemplate(
|
||||||
|
'Confirm Your New Email Address',
|
||||||
|
(data) => `
|
||||||
|
<h2 style="color: #333; margin-top: 0;">Hello ${data.userName || 'User'}!</h2>
|
||||||
|
<p style="color: #666; line-height: 1.6;">
|
||||||
|
You requested to change your email address from <strong>${data.oldEmail || 'your current email'}</strong>
|
||||||
|
to <strong>${data.newEmail || 'a new email'}</strong>.
|
||||||
|
</p>
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<a href="${data.verificationUrl || '#'}" style="${baseStyles.button}">
|
||||||
|
Confirm Email Change
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="color: #666; line-height: 1.6; margin-top: 20px; font-size: 14px;">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:
|
||||||
|
<br>
|
||||||
|
<a href="${data.verificationUrl || '#'}" style="color: #000000; text-decoration: underline; word-break: break-all;">
|
||||||
|
${data.verificationUrl || '#'}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color: #666; line-height: 1.6; margin-top: 20px; font-size: 14px;">
|
||||||
|
This link will expire in 24 hours. If you didn't request this change,
|
||||||
|
you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
(data) => `Confirm Your New Email Address - ${data.appName || 'App'}\n\nHello ${data.userName || 'User'}!\n\nYou requested to change your email from ${data.oldEmail} to ${data.newEmail}.\n\nConfirm: ${data.verificationUrl || '#'}\n\nThis link will expire in 24 hours.`
|
||||||
|
)
|
||||||
|
|
||||||
|
export const passwordResetEmailTemplate: EmailTemplate = buildEmailTemplate(
|
||||||
|
'Reset Your Password',
|
||||||
|
(data) => `
|
||||||
|
<h2 style="color: #333; margin-top: 0;">Hello ${data.userName || 'User'}!</h2>
|
||||||
|
<p style="color: #666; line-height: 1.6;">
|
||||||
|
You requested to reset your password for your ${data.appName || ''} account.
|
||||||
|
</p>
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<a href="${data.resetUrl || '#'}" style="${baseStyles.button}">
|
||||||
|
Reset Password
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="color: #666; line-height: 1.6; margin-top: 20px; font-size: 14px;">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:
|
||||||
|
<br>
|
||||||
|
<a href="${data.resetUrl || '#'}" style="color: #000000; text-decoration: underline; word-break: break-all;">
|
||||||
|
${data.resetUrl || '#'}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color: #666; line-height: 1.6; margin-top: 20px; font-size: 14px;">
|
||||||
|
This link will expire in 1 hour. If you didn't request a password reset,
|
||||||
|
you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
(data) => `Reset Your Password - ${data.appName || 'App'}\n\nHello ${data.userName || 'User'}!\n\nReset your password: ${data.resetUrl || '#'}\n\nThis link will expire in 1 hour.`
|
||||||
|
)
|
||||||
|
|
||||||
|
export const emailTemplates = {
|
||||||
|
test: testEmailTemplate,
|
||||||
|
welcome: welcomeEmailTemplate,
|
||||||
|
notification: notificationEmailTemplate,
|
||||||
|
bulk: bulkEmailTemplate,
|
||||||
|
verification: verificationEmailTemplate,
|
||||||
|
emailChangeVerification: emailChangeVerificationTemplate,
|
||||||
|
passwordReset: passwordResetEmailTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderEmailTemplate(
|
||||||
|
templateName: keyof typeof emailTemplates,
|
||||||
|
data: EmailTemplateData
|
||||||
|
): { subject: string; html: string; text: string } {
|
||||||
|
const template = emailTemplates[templateName]
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(`Email template '${templateName}' not found`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
subject: template.subject,
|
||||||
|
html: template.html(data),
|
||||||
|
text: template.text(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
151
server/utils/email.ts
Normal file
151
server/utils/email.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
import type { Transporter } from 'nodemailer'
|
||||||
|
import mailgunTransport from 'nodemailer-mailgun-transport'
|
||||||
|
import { renderEmailTemplate, type EmailTemplateData } from './email-templates'
|
||||||
|
|
||||||
|
const isDevelopment = (process.env.NODE_ENV || 'development') === 'development'
|
||||||
|
|
||||||
|
let transporter: Transporter | null = null
|
||||||
|
|
||||||
|
function getEmailConfig() {
|
||||||
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
return {
|
||||||
|
mailgunApiKey: config.mailgunApiKey || process.env.MAILGUN_API_KEY,
|
||||||
|
mailgunDomain: config.mailgunDomain || process.env.MAILGUN_DOMAIN,
|
||||||
|
mailgunHost: config.mailgunHost || process.env.MAILGUN_HOST,
|
||||||
|
smtpFrom: config.smtpFrom || process.env.SMTP_FROM,
|
||||||
|
smtpFromName: config.smtpFromName || process.env.SMTP_FROM_NAME,
|
||||||
|
appName: config.appName || process.env.APP_NAME
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
mailgunApiKey: process.env.MAILGUN_API_KEY,
|
||||||
|
mailgunDomain: process.env.MAILGUN_DOMAIN,
|
||||||
|
mailgunHost: process.env.MAILGUN_HOST,
|
||||||
|
smtpFrom: process.env.SMTP_FROM,
|
||||||
|
smtpFromName: process.env.SMTP_FROM_NAME,
|
||||||
|
appName: process.env.APP_NAME
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTransporter(): Transporter {
|
||||||
|
if (transporter) return transporter
|
||||||
|
|
||||||
|
const config = getEmailConfig()
|
||||||
|
|
||||||
|
if (isDevelopment) {
|
||||||
|
console.log('[Email] Using MailHog (development mode)')
|
||||||
|
transporter = nodemailer.createTransport({
|
||||||
|
host: 'localhost',
|
||||||
|
port: 1025,
|
||||||
|
secure: false,
|
||||||
|
tls: { rejectUnauthorized: false }
|
||||||
|
})
|
||||||
|
return transporter
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.mailgunApiKey || !config.mailgunDomain) {
|
||||||
|
throw new Error('Mailgun configuration incomplete. Set MAILGUN_API_KEY and MAILGUN_DOMAIN.')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Email] Using Mailgun HTTP API')
|
||||||
|
const mailgunOptions: any = {
|
||||||
|
auth: {
|
||||||
|
api_key: config.mailgunApiKey,
|
||||||
|
domain: config.mailgunDomain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.mailgunHost) {
|
||||||
|
mailgunOptions.host = config.mailgunHost
|
||||||
|
}
|
||||||
|
|
||||||
|
transporter = nodemailer.createTransport(mailgunTransport(mailgunOptions))
|
||||||
|
return transporter
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailOptions {
|
||||||
|
to: string | string[]
|
||||||
|
subject: string
|
||||||
|
html: string
|
||||||
|
text?: string
|
||||||
|
from?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateEmailOptions {
|
||||||
|
to: string | string[]
|
||||||
|
template: keyof typeof import('./email-templates').emailTemplates
|
||||||
|
data: EmailTemplateData
|
||||||
|
from?: string
|
||||||
|
subject?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmail(options: EmailOptions): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const config = getEmailConfig()
|
||||||
|
|
||||||
|
let fromEmail = options.from
|
||||||
|
if (!fromEmail) {
|
||||||
|
const fromName = config.smtpFromName || config.appName
|
||||||
|
const fromAddress = isDevelopment
|
||||||
|
? 'noreply@localhost.local'
|
||||||
|
: (config.smtpFrom || 'noreply@yourdomain.com')
|
||||||
|
fromEmail = fromName ? `${fromName} <${fromAddress}>` : fromAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: fromEmail,
|
||||||
|
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
|
||||||
|
subject: options.subject,
|
||||||
|
html: options.html,
|
||||||
|
text: options.text || options.html.replace(/<[^>]*>/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await getTransporter().sendMail(mailOptions)
|
||||||
|
console.log('[Email] Sent successfully:', info.messageId)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (!process.env.VITEST) {
|
||||||
|
console.error('[Email] Error sending:', error)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendTemplateEmail(options: TemplateEmailOptions): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const config = getEmailConfig()
|
||||||
|
const appName = config.appName || 'App'
|
||||||
|
|
||||||
|
const templateData = { ...options.data, appName }
|
||||||
|
const { subject, html, text } = renderEmailTemplate(options.template, templateData)
|
||||||
|
|
||||||
|
return await sendEmail({
|
||||||
|
to: options.to,
|
||||||
|
subject: options.subject || subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
from: options.from
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (!process.env.VITEST) {
|
||||||
|
console.error('[Email] Error sending template email:', error)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendBulkTemplateEmails(emails: TemplateEmailOptions[]): Promise<{ success: number; failed: number }> {
|
||||||
|
let success = 0
|
||||||
|
let failed = 0
|
||||||
|
|
||||||
|
for (const email of emails) {
|
||||||
|
const result = await sendTemplateEmail(email)
|
||||||
|
if (result) success++
|
||||||
|
else failed++
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success, failed }
|
||||||
|
}
|
||||||
78
server/utils/rate-limit.ts
Normal file
78
server/utils/rate-limit.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { sql } from 'kysely'
|
||||||
|
import { db } from './database'
|
||||||
|
import { logEvent } from './activity-logger'
|
||||||
|
|
||||||
|
interface RateLimitResult {
|
||||||
|
allowed: boolean
|
||||||
|
remaining: number
|
||||||
|
retryAfterSeconds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check rate limit by counting recent events in activity_logs.
|
||||||
|
* Uses metadata JSONB field to match identifier (email or IP).
|
||||||
|
*/
|
||||||
|
export async function checkRateLimit(
|
||||||
|
eventType: string,
|
||||||
|
identifierField: string,
|
||||||
|
identifierValue: string,
|
||||||
|
windowMs: number,
|
||||||
|
maxAttempts: number
|
||||||
|
): Promise<RateLimitResult> {
|
||||||
|
const windowStart = new Date(Date.now() - windowMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db
|
||||||
|
.selectFrom('activity_logs')
|
||||||
|
.select([
|
||||||
|
sql<number>`COUNT(*)::int`.as('count'),
|
||||||
|
sql<Date | null>`MIN(timestamp)`.as('oldest_attempt'),
|
||||||
|
])
|
||||||
|
.where('event_type', '=', eventType)
|
||||||
|
.where(sql`metadata->>${sql.lit(identifierField)}`, '=', identifierValue)
|
||||||
|
.where('timestamp', '>', windowStart)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
const count = result?.count || 0
|
||||||
|
const oldestAttempt = result?.oldest_attempt
|
||||||
|
|
||||||
|
if (count >= maxAttempts) {
|
||||||
|
const retryAfterMs = oldestAttempt
|
||||||
|
? (new Date(oldestAttempt).getTime() + windowMs) - Date.now()
|
||||||
|
: windowMs
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
remaining: 0,
|
||||||
|
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: maxAttempts - count
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Rate limit check failed:', error)
|
||||||
|
// Fail open - allow request if rate limit check fails
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: maxAttempts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log when rate limit is exceeded (for monitoring)
|
||||||
|
*/
|
||||||
|
export function logRateLimitExceeded(
|
||||||
|
identifier: string,
|
||||||
|
endpoint: string,
|
||||||
|
userAgent?: string
|
||||||
|
): void {
|
||||||
|
logEvent({
|
||||||
|
eventType: 'RATE_LIMIT_EXCEEDED',
|
||||||
|
userAgent,
|
||||||
|
metadata: { identifier, endpoint }
|
||||||
|
})
|
||||||
|
}
|
||||||
73
server/utils/rbac.ts
Normal file
73
server/utils/rbac.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import type { H3Event } from 'h3'
|
||||||
|
import { db } from './database'
|
||||||
|
import { requireAuth } from './auth'
|
||||||
|
import { getStaticRole, ROLES, STATIC_ROLE_NAMES } from '~~/app/utils/role-definitions'
|
||||||
|
import type { Permission } from '~~/app/utils/permissions'
|
||||||
|
|
||||||
|
export async function getUserRoles(userId: string): Promise<string[]> {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select('roles')
|
||||||
|
.where('id', '=', userId)
|
||||||
|
.executeTakeFirst()
|
||||||
|
|
||||||
|
return row?.roles ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves a list of role names to the union of permissions they grant.
|
||||||
|
// Baseline: static definitions only. The optional `custom-roles` block ships
|
||||||
|
// a replacement of this file that also falls back to the `custom_roles` table
|
||||||
|
// for names that aren't statically defined.
|
||||||
|
export async function getRolePermissions(roleNames: readonly string[]): Promise<Set<Permission>> {
|
||||||
|
const set = new Set<Permission>()
|
||||||
|
for (const roleName of roleNames) {
|
||||||
|
const staticRole = getStaticRole(roleName)
|
||||||
|
if (!staticRole) continue
|
||||||
|
for (const perm of staticRole.permissions) set.add(perm)
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserPermissions(userId: string): Promise<Set<Permission>> {
|
||||||
|
const roles = await getUserRoles(userId)
|
||||||
|
return getRolePermissions(roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userHasRole(userId: string, roleName: string): Promise<boolean> {
|
||||||
|
const roles = await getUserRoles(userId)
|
||||||
|
return roles.includes(roleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userHasPermission(userId: string, permission: Permission): Promise<boolean> {
|
||||||
|
const perms = await getUserPermissions(userId)
|
||||||
|
return perms.has(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireRole(event: H3Event, roleName: string) {
|
||||||
|
const authUser = requireAuth(event)
|
||||||
|
const ok = await userHasRole(authUser.userId, roleName)
|
||||||
|
if (!ok) {
|
||||||
|
throw createError({ statusCode: 403, statusMessage: `Role required: ${roleName}` })
|
||||||
|
}
|
||||||
|
return authUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requirePermission(event: H3Event, permission: Permission) {
|
||||||
|
const authUser = requireAuth(event)
|
||||||
|
const ok = await userHasPermission(authUser.userId, permission)
|
||||||
|
if (!ok) {
|
||||||
|
throw createError({ statusCode: 403, statusMessage: `Permission required: ${permission}` })
|
||||||
|
}
|
||||||
|
return authUser
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validates that every entry in `roles` is a known role name.
|
||||||
|
// v1: only static roles are known. The optional custom-roles block extends
|
||||||
|
// this to also consult the `custom_roles` table.
|
||||||
|
export async function validateRoleNames(roles: string[]): Promise<{ valid: boolean; unknown: string[] }> {
|
||||||
|
const known = new Set<string>(STATIC_ROLE_NAMES)
|
||||||
|
const unknown = roles.filter(r => !known.has(r))
|
||||||
|
return { valid: unknown.length === 0, unknown }
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ROLES, STATIC_ROLE_NAMES }
|
||||||
172
server/utils/storage.ts
Normal file
172
server/utils/storage.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
|
||||||
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
|
||||||
|
// S3 Client instance (will be initialized lazily)
|
||||||
|
let s3Client: S3Client | null = null
|
||||||
|
|
||||||
|
const SIGNED_URL_EXPIRATION = 60 * 60 * 24 * 7 // 7 days in seconds
|
||||||
|
|
||||||
|
// Get S3 settings from environment variables
|
||||||
|
function getS3Settings() {
|
||||||
|
// Try to get runtime config first (for production builds), then fallback to process.env
|
||||||
|
let S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET_NAME
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use runtime config if available (production build)
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
S3_ENDPOINT = config.s3Endpoint || process.env.S3_ENDPOINT
|
||||||
|
S3_REGION = config.s3Region || process.env.S3_REGION
|
||||||
|
S3_ACCESS_KEY_ID = config.s3AccessKeyId || process.env.S3_ACCESS_KEY_ID
|
||||||
|
S3_SECRET_ACCESS_KEY = config.s3SecretAccessKey || process.env.S3_SECRET_ACCESS_KEY
|
||||||
|
S3_BUCKET_NAME = config.s3BucketName || process.env.S3_BUCKET_NAME
|
||||||
|
} catch {
|
||||||
|
// Fallback to process.env (development mode)
|
||||||
|
S3_ENDPOINT = process.env.S3_ENDPOINT
|
||||||
|
S3_REGION = process.env.S3_REGION
|
||||||
|
S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID
|
||||||
|
S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY
|
||||||
|
S3_BUCKET_NAME = process.env.S3_BUCKET_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint: S3_ENDPOINT,
|
||||||
|
region: S3_REGION || 'us-west-004',
|
||||||
|
accessKeyId: S3_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: S3_SECRET_ACCESS_KEY,
|
||||||
|
bucketName: S3_BUCKET_NAME
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize S3 client with current settings
|
||||||
|
function initializeS3Client() {
|
||||||
|
const settings = getS3Settings()
|
||||||
|
|
||||||
|
if (!settings.endpoint || !settings.accessKeyId || !settings.secretAccessKey || !settings.bucketName) {
|
||||||
|
throw new Error('S3 configuration is incomplete. Please set S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, and S3_BUCKET_NAME environment variables.')
|
||||||
|
}
|
||||||
|
|
||||||
|
s3Client = new S3Client({
|
||||||
|
endpoint: settings.endpoint,
|
||||||
|
region: settings.region,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: settings.accessKeyId,
|
||||||
|
secretAccessKey: settings.secretAccessKey,
|
||||||
|
},
|
||||||
|
forcePathStyle: true, // Required for B2
|
||||||
|
})
|
||||||
|
|
||||||
|
return s3Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get S3 client (initialize if needed)
|
||||||
|
function getS3Client() {
|
||||||
|
if (!s3Client) {
|
||||||
|
return initializeS3Client()
|
||||||
|
}
|
||||||
|
return s3Client
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
url: string
|
||||||
|
key: string
|
||||||
|
filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to S3-compatible storage (Backblaze B2)
|
||||||
|
*/
|
||||||
|
export async function uploadToS3(
|
||||||
|
fileData: Buffer,
|
||||||
|
originalFilename: string,
|
||||||
|
contentType: string
|
||||||
|
): Promise<UploadResult> {
|
||||||
|
try {
|
||||||
|
const client = getS3Client()
|
||||||
|
const settings = getS3Settings()
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const ext = originalFilename.split('.').pop()
|
||||||
|
const randomName = randomBytes(16).toString('hex')
|
||||||
|
const key = `uploads/${randomName}.${ext}`
|
||||||
|
|
||||||
|
// Upload to S3
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: settings.bucketName,
|
||||||
|
Key: key,
|
||||||
|
Body: fileData,
|
||||||
|
ContentType: contentType,
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.send(command)
|
||||||
|
|
||||||
|
// Generate signed URL for private bucket access
|
||||||
|
const url = await generateSignedUrl(key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
key,
|
||||||
|
filename: originalFilename,
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('S3 upload error:', error)
|
||||||
|
throw new Error(`Failed to upload file to storage: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file from S3-compatible storage
|
||||||
|
*/
|
||||||
|
export async function deleteFromS3(key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const client = getS3Client()
|
||||||
|
const settings = getS3Settings()
|
||||||
|
|
||||||
|
const command = new DeleteObjectCommand({
|
||||||
|
Bucket: settings.bucketName,
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.send(command)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('S3 delete error:', error)
|
||||||
|
throw new Error(`Failed to delete file from storage: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a signed URL for accessing a file in private bucket
|
||||||
|
*/
|
||||||
|
export async function generateSignedUrl(key: string, expiresIn: number = SIGNED_URL_EXPIRATION): Promise<string> {
|
||||||
|
try {
|
||||||
|
const client = getS3Client()
|
||||||
|
const settings = getS3Settings()
|
||||||
|
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: settings.bucketName,
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = await getSignedUrl(client, command, { expiresIn })
|
||||||
|
return url
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('S3 signed URL generation error:', error)
|
||||||
|
throw new Error(`Failed to generate signed URL: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate file type for image uploads
|
||||||
|
*/
|
||||||
|
export function validateImageType(contentType: string): boolean {
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/heic', 'image/heif']
|
||||||
|
return allowedTypes.includes(contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate file size
|
||||||
|
*/
|
||||||
|
export function validateFileSize(fileSize: number, maxSizeMB: number = 5): boolean {
|
||||||
|
const maxSize = maxSizeMB * 1024 * 1024
|
||||||
|
return fileSize <= maxSize
|
||||||
|
}
|
||||||
10
tsconfig.json
Normal file
10
tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./.nuxt/tsconfig.app.json" },
|
||||||
|
{ "path": "./.nuxt/tsconfig.server.json" },
|
||||||
|
{ "path": "./.nuxt/tsconfig.shared.json" },
|
||||||
|
{ "path": "./.nuxt/tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue