mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(auth): add initial setup flow and harden auth/session handling
This commit is contained in:
@@ -51,12 +51,9 @@ Both must be running. The client proxies all `/api` and `/socket.io` requests to
|
||||
|
||||
### First steps after setup
|
||||
|
||||
1. **Log in** - a default admin account is created automatically:
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
2. **Change password** - you'll be prompted to set a new password on first login. Use `Admin123!` for local dev.
|
||||
3. **Create a library** - open Settings (gear icon) > Libraries > Create Library. Add a folder path pointing to some books on your machine, e.g. any directory containing `.epub`, `.pdf`, or `.cbz` files.
|
||||
4. **Scan** - the library scans automatically after creation. You should see books appear on the home page once the scan completes.
|
||||
1. **Complete initial setup** - you'll be redirected to `/setup` to create the first administrator account (username, password, name, and email).
|
||||
2. **Create a library** - open Settings (gear icon) > Libraries > Create Library. Add a folder path pointing to some books on your machine, e.g. any directory containing `.epub`, `.pdf`, or `.cbz` files.
|
||||
3. **Scan** - the library scans automatically after creation. You should see books appear on the home page once the scan completes.
|
||||
|
||||
> **Where does data go?** `BOOKS_PATH` in `server/.env` defaults to `../local/data` (resolves to `<project-root>/local/data`). This is the app's data directory where extracted cover images and thumbnails are stored (`local/data/covers/`). It is **not** where your book files live - those paths are configured per-library in the UI. The `local/` folder is gitignored.
|
||||
|
||||
@@ -225,6 +222,7 @@ Server environment is configured in `server/.env` (created from `.env.example` d
|
||||
| `JWT_SECRET` | `change-me-in-production` | JWT signing secret |
|
||||
| `JWT_EXPIRES_IN` | `15m` | Access token lifetime |
|
||||
| `JWT_REFRESH_EXPIRES_IN` | `7d` | Refresh token lifetime |
|
||||
| `SETUP_BOOTSTRAP_TOKEN` | (empty) | Required in production for initial `/api/v1/auth/setup` |
|
||||
| `BOOKS_PATH` | `../local/data` | App data directory for cover images (not where books live) |
|
||||
| `SMTP_*` | (empty) | Optional SMTP config for password-reset emails |
|
||||
| `APP_URL` | `http://localhost:5173` | Client URL (used in emails) |
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Moon, Sun } from 'lucide-vue-next'
|
||||
import { ACCENT_VIVID, ACCENT_PASTEL, ACCENT_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { useAuth } from './composables/useAuth'
|
||||
import { useOidc } from './composables/useOidc'
|
||||
import { useSetupStatus } from './composables/useSetupStatus'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
@@ -13,6 +14,7 @@ const currentAccent = computed(() => ACCENT_OPTIONS.find((o) => o.id === themeSt
|
||||
|
||||
const { login } = useAuth()
|
||||
const { getPublicConfig, initiateLogin } = useOidc()
|
||||
const { setupStatusError } = useSetupStatus()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
@@ -157,6 +159,7 @@ async function handleOidcLogin() {
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-sm text-destructive">{{ error }}</div>
|
||||
<div v-if="setupStatusError" class="text-sm text-destructive">{{ setupStatusError }}</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAuth } from './composables/useAuth'
|
||||
|
||||
const { setup } = useAuth()
|
||||
|
||||
const username = ref('')
|
||||
const name = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const setupToken = ref('')
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function handleSubmit() {
|
||||
error.value = null
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = 'Passwords do not match'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await setup({
|
||||
username: username.value,
|
||||
name: name.value,
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
setupToken: setupToken.value || undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to complete setup'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div class="w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-xl font-semibold text-foreground">Initial setup</h1>
|
||||
<p class="text-sm text-muted-foreground mt-1">Create the first administrator account.</p>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div class="space-y-1.5">
|
||||
<label for="setup-username" class="text-sm font-medium text-foreground">Username</label>
|
||||
<input
|
||||
id="setup-username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="setup-name" class="text-sm font-medium text-foreground">Full name</label>
|
||||
<input
|
||||
id="setup-name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
required
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="setup-email" class="text-sm font-medium text-foreground">Email</label>
|
||||
<input
|
||||
id="setup-email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="setup-password" class="text-sm font-medium text-foreground">Password</label>
|
||||
<input
|
||||
id="setup-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">Min. 8 characters with uppercase, lowercase, and a digit</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="setup-confirm-password" class="text-sm font-medium text-foreground">Confirm password</label>
|
||||
<input
|
||||
id="setup-confirm-password"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="setup-token" class="text-sm font-medium text-foreground">
|
||||
Setup token
|
||||
<span class="text-muted-foreground">(required in production)</span>
|
||||
</label>
|
||||
<input
|
||||
id="setup-token"
|
||||
v-model="setupToken"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-sm text-destructive">{{ error }}</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{{ loading ? 'Creating account…' : 'Create administrator account' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@ import { ref } from 'vue'
|
||||
import type { AuthUser, AuthResponse } from '@projectx/types'
|
||||
import { api, setAccessToken, setOnAuthFailure } from '@/lib/api'
|
||||
import router from '@/router'
|
||||
import { useSetupStatus } from './useSetupStatus'
|
||||
|
||||
const user = ref<AuthUser | null>(null)
|
||||
const isLoading = ref(false)
|
||||
@@ -13,7 +14,8 @@ function clearAuth() {
|
||||
|
||||
setOnAuthFailure(() => {
|
||||
clearAuth()
|
||||
router.push('/login')
|
||||
const { needsSetup } = useSetupStatus()
|
||||
router.push(needsSetup.value ? '/setup' : '/login')
|
||||
})
|
||||
|
||||
async function me(): Promise<void> {
|
||||
@@ -56,13 +58,44 @@ export function useAuth() {
|
||||
user.value = data.user
|
||||
|
||||
if (data.user.isDefaultPassword) {
|
||||
router.push('/change-password')
|
||||
router.push('/')
|
||||
} else {
|
||||
const redirect = router.currentRoute.value.query.redirect as string | undefined
|
||||
router.push(redirect ?? '/')
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(payload: { username: string; name: string; email: string; password: string; setupToken?: string }): Promise<void> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (payload.setupToken) {
|
||||
headers['x-setup-token'] = payload.setupToken
|
||||
}
|
||||
|
||||
const res = await fetch('/api/v1/auth/setup', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
username: payload.username,
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.message ?? 'Failed to complete setup')
|
||||
}
|
||||
|
||||
const data: AuthResponse = await res.json()
|
||||
setAccessToken(data.accessToken)
|
||||
user.value = data.user
|
||||
|
||||
useSetupStatus().markSetupComplete()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' })
|
||||
@@ -80,5 +113,5 @@ export function useAuth() {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return { user, isLoading, init, login, logout, me }
|
||||
return { user, isLoading, init, login, logout, me, setup }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const needsSetup = ref<boolean | null>(null)
|
||||
const setupStatusError = ref<string | null>(null)
|
||||
let inFlight: Promise<boolean> | null = null
|
||||
|
||||
export function useSetupStatus() {
|
||||
async function fetchSetupStatus(force = false): Promise<boolean> {
|
||||
if (inFlight) return inFlight
|
||||
if (!force && needsSetup.value !== null) return needsSetup.value
|
||||
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/setup-status', { credentials: 'include' })
|
||||
if (!res.ok) throw new Error('Failed to load setup status')
|
||||
const data = (await res.json()) as { needsSetup?: boolean }
|
||||
setupStatusError.value = null
|
||||
needsSetup.value = data.needsSetup === true
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load setup status'
|
||||
setupStatusError.value = message
|
||||
throw err
|
||||
} finally {
|
||||
inFlight = null
|
||||
}
|
||||
return needsSetup.value ?? false
|
||||
})()
|
||||
|
||||
return inFlight
|
||||
}
|
||||
|
||||
function markSetupComplete(): void {
|
||||
needsSetup.value = false
|
||||
setupStatusError.value = null
|
||||
}
|
||||
|
||||
return { needsSetup, setupStatusError, fetchSetupStatus, markSetupComplete }
|
||||
}
|
||||
@@ -24,7 +24,8 @@ const emit = defineEmits<{
|
||||
|
||||
const initial = computed(() => props.author.name.trim().charAt(0).toUpperCase() || '?')
|
||||
const fallbackStyle = computed(() => bookCoverStyle(props.author.name || String(props.author.id)))
|
||||
const hasImage = computed(() => Boolean(props.author.imageUrl) && !imageFailed.value)
|
||||
const imageSrc = computed(() => props.author.imageUrl ?? undefined)
|
||||
const hasImage = computed(() => Boolean(imageSrc.value) && !imageFailed.value)
|
||||
|
||||
const imageFailed = ref(false)
|
||||
watch(
|
||||
@@ -67,7 +68,7 @@ function handleDelete() {
|
||||
>
|
||||
<img
|
||||
v-if="hasImage"
|
||||
:src="author.imageUrl"
|
||||
:src="imageSrc"
|
||||
:alt="`${author.name} portrait`"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
|
||||
@@ -192,13 +192,15 @@ async function refreshSelectedAuthorsMetadata() {
|
||||
markRefreshing(ids)
|
||||
try {
|
||||
const result = await bulkRefreshAuthorsMetadata(ids, (event) => {
|
||||
if (event.imageUpdated && event.imageUrl) {
|
||||
if (event.imageUpdated) {
|
||||
const index = items.value.findIndex((item) => item.id === event.authorId)
|
||||
if (index !== -1) {
|
||||
const next = [...items.value]
|
||||
const current = next[index]
|
||||
if (!current) return
|
||||
next[index] = {
|
||||
...next[index],
|
||||
imageUrl: event.imageUrl,
|
||||
...current,
|
||||
imageUrl: event.imageUrl ?? null,
|
||||
}
|
||||
items.value = next
|
||||
}
|
||||
@@ -229,7 +231,9 @@ async function refreshSingleAuthorMetadata(authorId: number) {
|
||||
const index = items.value.findIndex((item) => item.id === authorId)
|
||||
if (index !== -1) {
|
||||
const next = [...items.value]
|
||||
next[index] = { ...next[index], ...updated }
|
||||
const current = next[index]
|
||||
if (!current) return
|
||||
next[index] = { ...current, ...updated }
|
||||
items.value = next
|
||||
}
|
||||
void maybeLoadSecondaryPanels()
|
||||
|
||||
+13
-4
@@ -6,16 +6,25 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useAuth } from './features/auth/composables/useAuth'
|
||||
import { useSetupStatus } from './features/auth/composables/useSetupStatus'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
|
||||
// Init auth before installing router — app.use(router) triggers the initial
|
||||
// navigation which runs the beforeEach guard. If auth isn't resolved yet,
|
||||
// user.value is null and the guard redirects every reload to /login.
|
||||
// Resolve setup status/auth before installing router.
|
||||
// app.use(router) triggers initial navigation and guard execution.
|
||||
const { fetchSetupStatus, needsSetup } = useSetupStatus()
|
||||
try {
|
||||
await fetchSetupStatus()
|
||||
} catch {
|
||||
// If setup-status check fails, continue with normal auth bootstrap.
|
||||
}
|
||||
|
||||
const { init } = useAuth()
|
||||
await init()
|
||||
if (needsSetup.value !== true) {
|
||||
await init()
|
||||
}
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||
import { useSetupStatus } from '@/features/auth/composables/useSetupStatus'
|
||||
|
||||
export function registerAuthGuard(router: Router): void {
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
const { fetchSetupStatus } = useSetupStatus()
|
||||
let requiresSetup = false
|
||||
try {
|
||||
requiresSetup = await fetchSetupStatus()
|
||||
} catch {
|
||||
// If setup-status cannot be loaded, fall back to normal auth checks.
|
||||
}
|
||||
if (requiresSetup && to.path !== '/setup') {
|
||||
return { path: '/setup' }
|
||||
}
|
||||
|
||||
if (!requiresSetup && to.path === '/setup') {
|
||||
const { user } = useAuth()
|
||||
return user.value ? { path: '/' } : { path: '/login' }
|
||||
}
|
||||
|
||||
if (to.meta.public) return true
|
||||
|
||||
const { user } = useAuth()
|
||||
|
||||
@@ -71,6 +71,12 @@ const router = createRouter({
|
||||
component: () => import('@/features/auth/LoginPage.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/setup',
|
||||
name: 'setup',
|
||||
component: () => import('@/features/auth/SetupPage.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
|
||||
@@ -9,6 +9,8 @@ NODE_ENV=development
|
||||
JWT_SECRET=change-me-in-production
|
||||
JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
# Required in production for initial /auth/setup (x-setup-token header)
|
||||
SETUP_BOOTSTRAP_TOKEN=
|
||||
|
||||
# Storage
|
||||
# App data directory for cover images and generated files.
|
||||
|
||||
@@ -13,6 +13,7 @@ export const authConfig = registerAs('auth', () => ({
|
||||
jwtSecret: process.env.JWT_SECRET ?? 'change-me-in-production',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '15m',
|
||||
jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN ?? '7d',
|
||||
setupBootstrapToken: process.env.SETUP_BOOTSTRAP_TOKEN ?? '',
|
||||
}));
|
||||
|
||||
export const storageConfig = registerAs('storage', () => ({
|
||||
|
||||
@@ -13,6 +13,7 @@ const envSchema = z.object({
|
||||
),
|
||||
JWT_EXPIRES_IN: z.string().default('15m'),
|
||||
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
|
||||
SETUP_BOOTSTRAP_TOKEN: z.string().optional(),
|
||||
BOOKS_PATH: z.string().default('/data/books'),
|
||||
STAGING_PATH: z.string().optional(),
|
||||
CLIENT_URL: z.string().url().optional(),
|
||||
@@ -33,5 +34,8 @@ export function validateEnv(config: Record<string, unknown>) {
|
||||
const errors = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n');
|
||||
throw new Error(`Environment validation failed:\n${errors}`);
|
||||
}
|
||||
if (result.data.NODE_ENV === 'production' && !result.data.SETUP_BOOTSTRAP_TOKEN?.trim()) {
|
||||
throw new Error('Environment validation failed:\n SETUP_BOOTSTRAP_TOKEN: SETUP_BOOTSTRAP_TOKEN is required in production');
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Req, Res } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Headers, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Req, Res } from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
@@ -11,6 +11,7 @@ import { LoginDto } from './dto/login.dto';
|
||||
import { OidcCallbackDto } from './dto/oidc-callback.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { SetupDto } from './dto/setup.dto';
|
||||
import { OidcService } from './oidc/oidc.service';
|
||||
|
||||
@Controller('auth')
|
||||
@@ -26,6 +27,19 @@ export class AuthController {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('setup-status')
|
||||
setupStatus() {
|
||||
return this.authService.setupStatus();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('setup')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
setup(@Body() dto: SetupDto, @Headers('x-setup-token') setupToken: string | undefined, @Res({ passthrough: true }) reply: FastifyReply) {
|
||||
return this.authService.setup(dto, setupToken, reply);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, ConflictException, ForbiddenException, ServiceUnav
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
function makeDb(overrides?: Partial<ReturnType<typeof makeDb>>) {
|
||||
function makeDb(overrides?: Record<string, unknown>) {
|
||||
const db: Record<string, unknown> = {
|
||||
query: {
|
||||
appSettings: { findFirst: jest.fn() },
|
||||
@@ -11,20 +11,18 @@ function makeDb(overrides?: Partial<ReturnType<typeof makeDb>>) {
|
||||
roles: { findFirst: jest.fn() },
|
||||
passwordResetTokens: { findFirst: jest.fn() },
|
||||
},
|
||||
$count: jest.fn().mockResolvedValue(0),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
onConflictDoNothing: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockResolvedValue([]),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockResolvedValue([{ total: 0 }]),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
transaction: jest.fn().mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const tx = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return fn(tx);
|
||||
}),
|
||||
transaction: jest.fn().mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => fn(db)),
|
||||
};
|
||||
return { ...db, ...overrides } as never;
|
||||
}
|
||||
@@ -65,7 +63,7 @@ function makeFullUser(overrides?: Partial<Record<string, unknown>>) {
|
||||
} as never;
|
||||
}
|
||||
|
||||
function makeService(dbOverrides?: Partial<ReturnType<typeof makeDb>>) {
|
||||
function makeService(dbOverrides?: Record<string, unknown>) {
|
||||
const db = makeDb(dbOverrides);
|
||||
const userService = {
|
||||
findByUsername: jest.fn(),
|
||||
@@ -83,6 +81,7 @@ function makeService(dbOverrides?: Partial<ReturnType<typeof makeDb>>) {
|
||||
if (key === 'auth.jwtRefreshExpiresIn') return '7d';
|
||||
if (key === 'auth.jwtExpiresIn') return '15m';
|
||||
if (key === 'app.nodeEnv') return 'test';
|
||||
if (key === 'auth.setupBootstrapToken') return 'bootstrap-token';
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
@@ -116,6 +115,80 @@ function makeService(dbOverrides?: Partial<ReturnType<typeof makeDb>>) {
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
describe('setupStatus', () => {
|
||||
it('returns needsSetup=true when there are no users', async () => {
|
||||
const { service, db } = makeService();
|
||||
((db as unknown as Record<string, unknown>).$count as jest.Mock).mockResolvedValue(0);
|
||||
|
||||
await expect(service.setupStatus()).resolves.toEqual({ needsSetup: true });
|
||||
});
|
||||
|
||||
it('returns needsSetup=false when at least one user exists', async () => {
|
||||
const { service, db } = makeService();
|
||||
((db as unknown as Record<string, unknown>).$count as jest.Mock).mockResolvedValue(1);
|
||||
|
||||
await expect(service.setupStatus()).resolves.toEqual({ needsSetup: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup', () => {
|
||||
it('throws ForbiddenException when setup token is invalid in production', async () => {
|
||||
const { service, config } = makeService();
|
||||
config.get.mockImplementation((key: string) => {
|
||||
if (key === 'app.nodeEnv') return 'production';
|
||||
if (key === 'auth.setupBootstrapToken') return 'expected-token';
|
||||
if (key === 'auth.jwtRefreshExpiresIn') return '7d';
|
||||
if (key === 'auth.jwtExpiresIn') return '15m';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.setup({ username: 'admin', name: 'Admin', email: 'admin@example.com', password: 'Admin1234' } as never, 'wrong-token', makeReply()),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('throws ConflictException when setup is already completed', async () => {
|
||||
const { service, db } = makeService();
|
||||
((db as unknown as Record<string, unknown>).returning as jest.Mock).mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
service.setup({ username: 'admin', name: 'Admin', email: 'admin@example.com', password: 'Admin1234' } as never, undefined, makeReply()),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('creates initial admin and returns auth payload', async () => {
|
||||
const { service, db, userService } = makeService();
|
||||
const reply = makeReply();
|
||||
|
||||
((db as unknown as Record<string, unknown>).returning as jest.Mock)
|
||||
.mockResolvedValueOnce([{ id: 99 }])
|
||||
.mockResolvedValueOnce([{ id: 7, tokenVersion: 1 }]);
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).users.findFirst.mockResolvedValue(null);
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).roles.findFirst.mockResolvedValue({ id: 1, name: 'Admin' });
|
||||
userService.findByIdWithRolesAndPermissions.mockResolvedValue(
|
||||
makeFullUser({
|
||||
id: 7,
|
||||
username: 'owner',
|
||||
name: 'Owner',
|
||||
email: 'owner@example.com',
|
||||
roles: [{ id: 1, name: 'Admin', description: '', isSuperuser: true, isSystem: true, permissions: [] }],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.setup(
|
||||
{ username: 'owner', name: 'Owner', email: 'owner@example.com', password: 'Owner1234' } as never,
|
||||
undefined,
|
||||
reply,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accessToken: 'signed-jwt',
|
||||
user: { id: 7, username: 'owner', email: 'owner@example.com' },
|
||||
});
|
||||
expect((reply as unknown as { setCookie: jest.Mock }).setCookie).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('throws ForbiddenException when registration is closed', async () => {
|
||||
const { service, db } = makeService();
|
||||
@@ -127,9 +200,9 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('throws ConflictException when username already exists', async () => {
|
||||
const { service, db, userService } = makeService();
|
||||
const { service, db } = makeService();
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).appSettings.findFirst.mockResolvedValue({ value: 'true' });
|
||||
userService.findByUsername.mockResolvedValue({ id: 99, username: 'existing' });
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).users.findFirst.mockResolvedValueOnce({ id: 99, username: 'existing' });
|
||||
|
||||
await expect(service.register({ username: 'existing', name: 'E', password: 'P@ssw0rd!', email: undefined } as never)).rejects.toThrow(
|
||||
ConflictException,
|
||||
@@ -137,10 +210,11 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('throws ConflictException when email already in use', async () => {
|
||||
const { service, db, userService } = makeService();
|
||||
const { service, db } = makeService();
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).appSettings.findFirst.mockResolvedValue({ value: 'true' });
|
||||
userService.findByUsername.mockResolvedValue(null);
|
||||
userService.findByEmail.mockResolvedValue({ id: 88, email: 'existing@example.com' });
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).users.findFirst
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 88, email: 'existing@example.com' });
|
||||
|
||||
await expect(
|
||||
service.register({ username: 'newuser', name: 'N', password: 'P@ssw0rd!', email: 'existing@example.com' } as never),
|
||||
@@ -148,14 +222,11 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('registers user successfully and assigns User role', async () => {
|
||||
const { service, db, userService } = makeService();
|
||||
const { service, db } = makeService();
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).appSettings.findFirst.mockResolvedValue({ value: 'true' });
|
||||
userService.findByUsername.mockResolvedValue(null);
|
||||
userService.findByEmail.mockResolvedValue(null);
|
||||
userService.create.mockResolvedValue({ id: 1, username: 'jdoe', name: 'John Doe' });
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).users.findFirst.mockResolvedValueOnce(null);
|
||||
((db as unknown as Record<string, unknown>).returning as jest.Mock).mockResolvedValueOnce([{ id: 1, username: 'jdoe', name: 'John Doe' }]);
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).roles.findFirst.mockResolvedValue({ id: 5, name: 'User' });
|
||||
(db.insert as jest.Mock).mockReturnThis();
|
||||
(db.values as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.register({ username: 'jdoe', name: 'John Doe', password: 'P@ssw0rd!', email: undefined } as never);
|
||||
expect(result).toEqual({ id: 1, username: 'jdoe', name: 'John Doe' });
|
||||
@@ -279,6 +350,27 @@ describe('AuthService', () => {
|
||||
|
||||
await expect(service.refresh(makeRequest({ refresh_token: 'expired-token' }), makeReply())).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rotates token and sets cookies when refresh succeeds', async () => {
|
||||
const { service, db } = makeService();
|
||||
const reply = makeReply();
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).refreshTokens.findFirst.mockResolvedValue({
|
||||
id: 11,
|
||||
userId: 5,
|
||||
revokedAt: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).users.findFirst.mockResolvedValue({
|
||||
id: 5,
|
||||
tokenVersion: 2,
|
||||
active: true,
|
||||
});
|
||||
|
||||
const result = await service.refresh(makeRequest({ refresh_token: 'ok-token' }), reply);
|
||||
expect(result).toEqual({ accessToken: 'signed-jwt' });
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
expect((reply as unknown as { setCookie: jest.Mock }).setCookie).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUser', () => {
|
||||
@@ -357,15 +449,27 @@ describe('AuthService', () => {
|
||||
|
||||
await expect(service.changePassword(1, { currentPassword: 'old', newPassword: 'New@1234' }, makeReply())).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when current password is wrong', async () => {
|
||||
const { service, db } = makeService();
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).users.findFirst.mockResolvedValue({
|
||||
id: 1,
|
||||
provisioningMethod: 'local',
|
||||
passwordHash: '$2b$12$N4G7fngl8wXlWv2vN7INzuLe6Qw3sJwN6gI6s2zQm6A2f0r7WQX1y', // hash for a different password
|
||||
});
|
||||
|
||||
await expect(service.changePassword(1, { currentPassword: 'wrong-current', newPassword: 'New@1234' }, makeReply())).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessions', () => {
|
||||
it('filters out expired sessions', async () => {
|
||||
it('returns active sessions', async () => {
|
||||
const { service, db } = makeService();
|
||||
const now = new Date();
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).refreshTokens.findMany.mockResolvedValue([
|
||||
{ id: 1, createdAt: new Date(now.getTime() - 1000), expiresAt: new Date(now.getTime() + 60000) },
|
||||
{ id: 2, createdAt: new Date(now.getTime() - 2000), expiresAt: new Date(now.getTime() - 1000) },
|
||||
]);
|
||||
|
||||
const sessions = await service.getSessions(1);
|
||||
@@ -374,6 +478,44 @@ describe('AuthService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeSession', () => {
|
||||
it('throws ForbiddenException when session belongs to another user', async () => {
|
||||
const { service, db } = makeService();
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).refreshTokens.findFirst.mockResolvedValue({
|
||||
id: 9,
|
||||
userId: 999,
|
||||
});
|
||||
|
||||
await expect(service.revokeSession(1, 9)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetPassword', () => {
|
||||
it('throws BadRequestException when reset token is expired', async () => {
|
||||
const { service, db } = makeService();
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).passwordResetTokens.findFirst.mockResolvedValue({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
expiresAt: new Date(Date.now() - 10_000),
|
||||
usedAt: null,
|
||||
});
|
||||
|
||||
await expect(service.resetPassword({ token: 'expired', newPassword: 'NewPassword1' })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when reset token is already used', async () => {
|
||||
const { service, db } = makeService();
|
||||
(db.query as never as Record<string, Record<string, jest.Mock>>).passwordResetTokens.findFirst.mockResolvedValue({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
expiresAt: new Date(Date.now() + 10_000),
|
||||
usedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(service.resetPassword({ token: 'used', newPassword: 'NewPassword1' })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('returns empty object when no refresh cookie', async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
InternalServerErrorException,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
@@ -13,7 +14,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { compare, hash } from 'bcryptjs';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import '@fastify/cookie';
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { and, count, eq, gt, isNull, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
@@ -28,6 +29,7 @@ import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { SetupDto } from './dto/setup.dto';
|
||||
import { OidcDiscoveryService } from './oidc/oidc-discovery.service';
|
||||
import { OidcSessionRepository } from './oidc/oidc-session.repository';
|
||||
|
||||
@@ -43,6 +45,8 @@ function sha256(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
const INITIAL_SETUP_KEY = 'initial_setup_completed_at';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
@@ -66,33 +70,99 @@ export class AuthService {
|
||||
throw new ForbiddenException('Registration is not open');
|
||||
}
|
||||
|
||||
const existingUsername = await this.userService.findByUsername(dto.username);
|
||||
if (existingUsername) throw new ConflictException('Username already taken');
|
||||
|
||||
if (dto.email) {
|
||||
const existingEmail = await this.userService.findByEmail(dto.email);
|
||||
if (existingEmail) throw new ConflictException('Email already in use');
|
||||
}
|
||||
|
||||
const passwordHash = await hash(dto.password, 12);
|
||||
const user = await this.userService.create({
|
||||
username: dto.username,
|
||||
name: dto.name,
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
isDefaultPassword: false,
|
||||
return this.db.transaction(async (tx) => {
|
||||
const existingUsername = await tx.query.users.findFirst({ where: eq(schema.users.username, dto.username) });
|
||||
if (existingUsername) throw new ConflictException('Username already taken');
|
||||
|
||||
if (dto.email) {
|
||||
const existingEmail = await tx.query.users.findFirst({ where: eq(schema.users.email, dto.email) });
|
||||
if (existingEmail) throw new ConflictException('Email already in use');
|
||||
}
|
||||
|
||||
const [user] = await tx
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
username: dto.username,
|
||||
name: dto.name,
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
isDefaultPassword: false,
|
||||
})
|
||||
.returning({ id: schema.users.id, username: schema.users.username, name: schema.users.name });
|
||||
|
||||
const userRole = await tx.query.roles.findFirst({
|
||||
where: eq(schema.roles.name, 'User'),
|
||||
});
|
||||
if (!userRole) {
|
||||
this.logger.error(`User role not found while registering ${user.username}`);
|
||||
throw new InternalServerErrorException('User role not found');
|
||||
}
|
||||
|
||||
await tx.insert(schema.userRoles).values({ userId: user.id, roleId: userRole.id });
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
async setupStatus(): Promise<{ needsSetup: boolean }> {
|
||||
const count = await this.db.$count(schema.users);
|
||||
return { needsSetup: count === 0 };
|
||||
}
|
||||
|
||||
async setup(dto: SetupDto, setupToken: string | undefined, reply: FastifyReply) {
|
||||
this.assertSetupToken(setupToken);
|
||||
const passwordHash = await hash(dto.password, 12);
|
||||
|
||||
const created = await this.db.transaction(async (tx) => {
|
||||
const [setupMarker] = await tx
|
||||
.insert(schema.appSettings)
|
||||
.values({ key: INITIAL_SETUP_KEY, value: new Date().toISOString() })
|
||||
.onConflictDoNothing({ target: schema.appSettings.key })
|
||||
.returning({ id: schema.appSettings.id });
|
||||
if (!setupMarker) {
|
||||
throw new ConflictException('Setup already completed');
|
||||
}
|
||||
|
||||
const [{ total }] = await tx.select({ total: count() }).from(schema.users);
|
||||
if (Number(total) > 0) {
|
||||
throw new ConflictException('Setup already completed');
|
||||
}
|
||||
|
||||
const existingUsername = await tx.query.users.findFirst({ where: eq(schema.users.username, dto.username) });
|
||||
if (existingUsername) {
|
||||
throw new ConflictException('Username already taken');
|
||||
}
|
||||
|
||||
const existingEmail = await tx.query.users.findFirst({ where: eq(schema.users.email, dto.email) });
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('Email already in use');
|
||||
}
|
||||
|
||||
const [user] = await tx
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
username: dto.username,
|
||||
name: dto.name,
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
isDefaultPassword: false,
|
||||
})
|
||||
.returning({
|
||||
id: schema.users.id,
|
||||
tokenVersion: schema.users.tokenVersion,
|
||||
});
|
||||
|
||||
const adminRole = await tx.query.roles.findFirst({ where: eq(schema.roles.name, 'Admin') });
|
||||
if (!adminRole) {
|
||||
throw new InternalServerErrorException('Admin role not found');
|
||||
}
|
||||
|
||||
await tx.insert(schema.userRoles).values({ userId: user.id, roleId: adminRole.id }).onConflictDoNothing();
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
const userRole = await this.db.query.roles.findFirst({
|
||||
where: eq(schema.roles.name, 'User'),
|
||||
});
|
||||
if (userRole) {
|
||||
await this.db.insert(schema.userRoles).values({ userId: user.id, roleId: userRole.id });
|
||||
} else {
|
||||
this.logger.warn(`User role not found — registered user ${user.username} has no role`);
|
||||
}
|
||||
|
||||
return { id: user.id, username: user.username, name: user.name };
|
||||
return this.issueTokensForUser(created.id, reply);
|
||||
}
|
||||
|
||||
async login(dto: LoginDto, reply: FastifyReply) {
|
||||
@@ -174,6 +244,11 @@ export class AuthService {
|
||||
|
||||
const userForToken = await this.db.query.users.findFirst({ where: eq(schema.users.id, row.userId) });
|
||||
if (!userForToken) throw new UnauthorizedException();
|
||||
if (!userForToken.active) {
|
||||
this.clearRefreshCookie(reply);
|
||||
this.clearAccessCookie(reply);
|
||||
throw new UnauthorizedException('Account disabled');
|
||||
}
|
||||
|
||||
const { accessToken, rawRefreshToken } = await this.issueTokenPair(row.userId, userForToken.tokenVersion);
|
||||
this.setRefreshCookie(reply, rawRefreshToken);
|
||||
@@ -236,16 +311,17 @@ export class AuthService {
|
||||
|
||||
async getSessions(userId: number) {
|
||||
const rows = await this.db.query.refreshTokens.findMany({
|
||||
where: and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt)),
|
||||
where: and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt), gt(schema.refreshTokens.expiresAt, new Date())),
|
||||
});
|
||||
return rows.filter((r) => r.expiresAt > new Date()).map(({ id, createdAt, expiresAt }) => ({ id, createdAt, expiresAt }));
|
||||
return rows.map(({ id, createdAt, expiresAt }) => ({ id, createdAt, expiresAt }));
|
||||
}
|
||||
|
||||
async revokeSession(userId: number, sessionId: number) {
|
||||
const row = await this.db.query.refreshTokens.findFirst({
|
||||
where: eq(schema.refreshTokens.id, sessionId),
|
||||
});
|
||||
if (!row || row.userId !== userId) throw new UnauthorizedException();
|
||||
if (!row) throw new UnauthorizedException();
|
||||
if (row.userId !== userId) throw new ForbiddenException('You do not have access to this session');
|
||||
await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, sessionId));
|
||||
}
|
||||
|
||||
@@ -334,6 +410,16 @@ export class AuthService {
|
||||
return { accessToken, rawRefreshToken };
|
||||
}
|
||||
|
||||
private assertSetupToken(setupToken: string | undefined) {
|
||||
const isProduction = this.config.get<string>('app.nodeEnv') === 'production';
|
||||
if (!isProduction) return;
|
||||
|
||||
const expected = this.config.get<string>('auth.setupBootstrapToken') ?? '';
|
||||
if (!expected || setupToken !== expected) {
|
||||
throw new ForbiddenException('Invalid setup token');
|
||||
}
|
||||
}
|
||||
|
||||
private setRefreshCookie(reply: FastifyReply, rawToken: string) {
|
||||
const ttlSeconds = parseDurationMs(this.config.get<string>('auth.jwtRefreshExpiresIn') ?? '7d') / 1000;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { IsEmail, IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class SetupDto {
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name: string;
|
||||
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, {
|
||||
message: 'Password must contain at least one uppercase letter, one lowercase letter, and one digit',
|
||||
})
|
||||
password: string;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { Inject, Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
@@ -49,25 +48,20 @@ const USER_PERMISSIONS = [
|
||||
|
||||
@Injectable()
|
||||
export class SeedService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(SeedService.name);
|
||||
|
||||
constructor(@Inject(DB) private readonly db: NodePgDatabase<typeof schema>) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
await this.seedPermissions();
|
||||
await this.seedRoles();
|
||||
await this.seedAppSettings();
|
||||
await this.seedDefaultAdmin();
|
||||
await this.seedEmailDefaults();
|
||||
}
|
||||
|
||||
private async seedPermissions() {
|
||||
for (const perm of BUILT_IN_PERMISSIONS) {
|
||||
await this.db
|
||||
.insert(schema.permissions)
|
||||
.values(perm)
|
||||
.onConflictDoUpdate({ target: schema.permissions.name, set: { isSystem: true } });
|
||||
}
|
||||
await this.db
|
||||
.insert(schema.permissions)
|
||||
.values(BUILT_IN_PERMISSIONS)
|
||||
.onConflictDoUpdate({ target: schema.permissions.name, set: { isSystem: true } });
|
||||
}
|
||||
|
||||
private async seedRoles() {
|
||||
@@ -99,9 +93,11 @@ export class SeedService implements OnApplicationBootstrap {
|
||||
const perms = await this.db.query.permissions.findMany({
|
||||
where: inArray(schema.permissions.name, permissionNames),
|
||||
});
|
||||
for (const perm of perms) {
|
||||
await this.db.insert(schema.rolePermissions).values({ roleId, permissionId: perm.id }).onConflictDoNothing();
|
||||
}
|
||||
if (perms.length === 0) return;
|
||||
await this.db
|
||||
.insert(schema.rolePermissions)
|
||||
.values(perms.map((perm) => ({ roleId, permissionId: perm.id })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
private async seedAppSettings() {
|
||||
@@ -189,27 +185,4 @@ export class SeedService implements OnApplicationBootstrap {
|
||||
await this.db.insert(schema.appSettings).values(setting).onConflictDoNothing({ target: schema.appSettings.key });
|
||||
}
|
||||
}
|
||||
|
||||
private async seedDefaultAdmin() {
|
||||
const count = await this.db.$count(schema.users);
|
||||
if (count > 0) return;
|
||||
|
||||
const passwordHash = await hash('admin', 12);
|
||||
const [user] = await this.db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
username: 'admin',
|
||||
name: 'Administrator',
|
||||
passwordHash,
|
||||
isDefaultPassword: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const adminRole = await this.db.query.roles.findFirst({ where: eq(schema.roles.name, 'Admin') });
|
||||
if (adminRole) {
|
||||
await this.db.insert(schema.userRoles).values({ userId: user.id, roleId: adminRole.id });
|
||||
}
|
||||
|
||||
this.logger.warn('WARNING: Default admin account created. Login with admin/admin and change your password immediately.');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user