This commit is contained in:
corsacca 2026-04-21 12:12:54 +01:00
commit 6f5313f990
64 changed files with 7928 additions and 0 deletions

14
.blueprints.json Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,8 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'green',
neutral: 'slate'
}
}
})

7
app/app.vue Normal file
View file

@ -0,0 +1,7 @@
<template>
<UApp>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>

25
app/assets/css/main.css Normal file
View 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;
}
}

View 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
View 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 }
}
}

View 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
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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>

View 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
View 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)
}

View 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
}

2498
bun.lock Normal file

File diff suppressed because it is too large Load diff

6
eslint.config.mjs Normal file
View file

@ -0,0 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)

View 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()
}

View 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()
}

View 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()
}

View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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
}
})

View 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 }
})

View 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 }
})

View 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 } }
})

View 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
}
})

View 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]
}
}
})

View 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
View 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]
}
}
})

View 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.'
}
})

View 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'
})
}
})

View 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'
})
}
})

View 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' })
}
})

View 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'
}
})

View 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'
})
}
})

View 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
}
})

View 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
View 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
}

View 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')
})

View 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
View 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
View 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 }

View 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
View 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 }
}

View 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
View 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
View 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
View 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" }
]
}