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