diff --git a/apps/blog-next/app/auth-nav.tsx b/apps/blog-next/app/auth-nav.tsx
index 91416f08..e2c5f7a9 100644
--- a/apps/blog-next/app/auth-nav.tsx
+++ b/apps/blog-next/app/auth-nav.tsx
@@ -1,9 +1,8 @@
'use client'
-import { useState } from 'react'
import Link from 'next/link'
-import { useRouter } from 'next/navigation'
import { useAuth } from '@holo-js/auth/next/client'
+import { logoutAction } from './logout/actions'
const linkStyle = {
color: '#cbd5e1',
@@ -25,37 +24,8 @@ const logoutFormStyle = {
export function AuthNav() {
const auth = useAuth()
- const router = useRouter()
- const [isLoggingOut, setIsLoggingOut] = useState(false)
const displayName = auth.user?.name ?? auth.user?.email ?? 'Account'
- async function logout() {
- if (isLoggingOut) {
- return
- }
-
- setIsLoggingOut(true)
- try {
- const response = await fetch('/api/logout', { method: 'POST' })
- if (!response.ok) {
- console.warn('Logout failed.', { status: response.status })
- return
- }
-
- try {
- await auth.refreshUser()
- } catch (error) {
- console.warn('Auth refresh failed after logout.', error)
- }
-
- router.replace('/')
- } catch (error) {
- console.warn('Logout failed.', error)
- } finally {
- setIsLoggingOut(false)
- }
- }
-
if (!auth.authenticated) {
return (
<>
@@ -68,7 +38,9 @@ export function AuthNav() {
return (
<>
{displayName}
-
+
{auth.provider === 'workos' && (
- {form.lastSubmission?.ok === true ? (
-
-
Signed in successfully.
-
Continue to admin
-
- ) : null}
-
- Create account
+ Create account
Forgot password?
diff --git a/apps/blog-next/app/logout/actions.ts b/apps/blog-next/app/logout/actions.ts
new file mode 100644
index 00000000..6f7c5cc4
--- /dev/null
+++ b/apps/blog-next/app/logout/actions.ts
@@ -0,0 +1,11 @@
+'use server'
+
+import { logout } from '@holo-js/auth'
+import { revalidatePath } from 'next/cache'
+import { redirect } from 'next/navigation'
+
+export async function logoutAction() {
+ await logout()
+ revalidatePath('/', 'layout')
+ redirect('/')
+}
diff --git a/apps/blog-next/app/register/actions.ts b/apps/blog-next/app/register/actions.ts
new file mode 100644
index 00000000..7b7e973f
--- /dev/null
+++ b/apps/blog-next/app/register/actions.ts
@@ -0,0 +1,35 @@
+'use server'
+
+import { loginUsing, register } from '@holo-js/auth'
+import { validate } from '@holo-js/forms'
+import { revalidatePath } from 'next/cache'
+import { redirect } from 'next/navigation'
+
+import { registerForm } from '@/lib/schemas/auth'
+
+export async function registerAction(formData: FormData) {
+ const submission = await validate(formData, registerForm, {
+ csrf: true,
+ throttle: 'register',
+ })
+
+ if (!submission.valid) {
+ return submission.fail()
+ }
+
+ const { data: created, error } = await register(submission.data)
+ if (error) {
+ return submission.fail({
+ status: error.status,
+ errors: error.fields,
+ })
+ }
+
+ const session = await loginUsing(created)
+ const redirectTo = session.emailVerificationRequired
+ ? session.emailVerificationRoute ?? '/verify-email'
+ : '/admin'
+
+ revalidatePath('/', 'layout')
+ redirect(redirectTo)
+}
diff --git a/apps/blog-next/app/register/page.tsx b/apps/blog-next/app/register/page.tsx
index 9c1dbe7d..71a2ff87 100644
--- a/apps/blog-next/app/register/page.tsx
+++ b/apps/blog-next/app/register/page.tsx
@@ -1,11 +1,11 @@
'use client'
import Link from 'next/link'
-import { useRouter } from 'next/navigation'
import { useForm } from '@holo-js/adapter-next/client'
import { registerForm } from '@/lib/schemas/auth'
+import { registerAction } from './actions'
const panelStyle = {
display: 'grid',
@@ -18,18 +18,12 @@ const panelStyle = {
} satisfies React.CSSProperties
export default function RegisterPage() {
- const router = useRouter()
const form = useForm(registerForm, {
csrf: true,
validateOn: 'blur',
initialValues: { name: '', email: '', password: '', passwordConfirmation: '' },
async submitter({ formData }) {
- const response = await fetch('/api/register', { method: 'POST', body: formData })
- const submission = await response.json()
- if (submission?.ok === true) {
- router.replace('/login')
- }
- return submission
+ return await registerAction(formData)
},
})
@@ -93,13 +87,6 @@ export default function RegisterPage() {
- {form.lastSubmission?.ok === true ? (
-
-
Account created. Check your inbox to verify your email address.
-
Return to sign in
-
- ) : null}
-
Already have an account?
Register with WorkOS
Register with Clerk
diff --git a/apps/blog-next/app/super-admin/login/actions.ts b/apps/blog-next/app/super-admin/login/actions.ts
new file mode 100644
index 00000000..3617c2f5
--- /dev/null
+++ b/apps/blog-next/app/super-admin/login/actions.ts
@@ -0,0 +1,33 @@
+'use server'
+
+import auth from '@holo-js/auth'
+import { validate } from '@holo-js/forms'
+import { revalidatePath } from 'next/cache'
+import { redirect } from 'next/navigation'
+
+import { loginForm } from '@/lib/schemas/auth'
+
+export async function superAdminLoginAction(formData: FormData) {
+ const submission = await validate(formData, loginForm, {
+ throttle: 'login',
+ })
+
+ if (!submission.valid) {
+ return submission.fail()
+ }
+
+ const { data: session, error } = await auth.guard('admin').login(submission.data)
+ if (error) {
+ return submission.fail({
+ status: error.status,
+ errors: error.fields,
+ })
+ }
+
+ const redirectTo = session.emailVerificationRequired
+ ? session.emailVerificationRoute ?? '/verify-email'
+ : '/super-admin'
+
+ revalidatePath('/', 'layout')
+ redirect(redirectTo)
+}
diff --git a/apps/blog-next/app/super-admin/login/page.tsx b/apps/blog-next/app/super-admin/login/page.tsx
index 925d97f2..cbcf1bbf 100644
--- a/apps/blog-next/app/super-admin/login/page.tsx
+++ b/apps/blog-next/app/super-admin/login/page.tsx
@@ -1,9 +1,8 @@
'use client'
-import { useRouter } from 'next/navigation'
-import { useAuth } from '@holo-js/auth/next/client'
import { useForm } from '@holo-js/adapter-next/client'
import { loginForm } from '@/lib/schemas/auth'
+import { superAdminLoginAction } from './actions'
const panelStyle = {
display: 'grid',
@@ -16,23 +15,11 @@ const panelStyle = {
} satisfies React.CSSProperties
export default function SuperAdminLoginPage() {
- const router = useRouter()
- const auth = useAuth({ guard: 'admin' })
const form = useForm(loginForm, {
validateOn: 'blur',
initialValues: { email: '', password: '', remember: false },
async submitter({ formData }) {
- const response = await fetch('/api/super-admin/login', { method: 'POST', body: formData })
- const submission = await response.json()
- if (submission?.ok === true && typeof submission.data?.redirectTo === 'string') {
- try {
- await auth.refreshUser()
- } catch (error) {
- console.warn('Super admin auth refresh failed after login.', error)
- }
- router.replace(submission.data.redirectTo)
- }
- return submission
+ return await superAdminLoginAction(formData)
},
})
const formError = form.errors.first('_root')
@@ -86,11 +73,6 @@ export default function SuperAdminLoginPage() {
- {form.lastSubmission?.ok === true ? (
-
-
Signed in as super admin.
-
- ) : null}
)
}
diff --git a/apps/blog-next/app/super-admin/logout-button.tsx b/apps/blog-next/app/super-admin/logout-button.tsx
index 903c14a0..cd462059 100644
--- a/apps/blog-next/app/super-admin/logout-button.tsx
+++ b/apps/blog-next/app/super-admin/logout-button.tsx
@@ -1,44 +1,9 @@
-'use client'
-
-import { useState } from 'react'
-import { useRouter } from 'next/navigation'
-import { useAuth } from '@holo-js/auth/next/client'
+import { superAdminLogoutAction } from './logout/actions'
export function SuperAdminLogoutButton() {
- const router = useRouter()
- const auth = useAuth({ guard: 'admin' })
- const [isLoggingOut, setIsLoggingOut] = useState(false)
-
- async function logout() {
- if (isLoggingOut) {
- return
- }
-
- setIsLoggingOut(true)
- try {
- const response = await fetch('/api/super-admin/logout', { method: 'POST' })
- if (!response.ok) {
- console.warn('Super admin logout failed.', { status: response.status })
- return
- }
-
- try {
- await auth.refreshUser()
- } catch (error) {
- console.warn('Super admin auth refresh failed after logout.', error)
- }
-
- router.replace('/super-admin/login')
- } catch (error) {
- console.warn('Super admin logout failed.', error)
- } finally {
- setIsLoggingOut(false)
- }
- }
-
return (
-
+
)
}
diff --git a/apps/blog-next/app/super-admin/logout/actions.ts b/apps/blog-next/app/super-admin/logout/actions.ts
new file mode 100644
index 00000000..14580a6f
--- /dev/null
+++ b/apps/blog-next/app/super-admin/logout/actions.ts
@@ -0,0 +1,11 @@
+'use server'
+
+import auth from '@holo-js/auth'
+import { revalidatePath } from 'next/cache'
+import { redirect } from 'next/navigation'
+
+export async function superAdminLogoutAction() {
+ await auth.guard('admin').logout()
+ revalidatePath('/', 'layout')
+ redirect('/super-admin/login')
+}
diff --git a/apps/blog-next/tests/auth-nav.test.mjs b/apps/blog-next/tests/auth-nav.test.mjs
index 97c9c500..66d1572f 100644
--- a/apps/blog-next/tests/auth-nav.test.mjs
+++ b/apps/blog-next/tests/auth-nav.test.mjs
@@ -1,18 +1,15 @@
import { jsx } from 'react/jsx-runtime'
import { act, create } from 'react-test-renderer'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
- fetch: vi.fn(),
- refreshUser: vi.fn(),
- replace: vi.fn(),
+ logoutAction: vi.fn(),
}))
vi.mock('@holo-js/auth/next/client', () => ({
useAuth: () => ({
authenticated: true,
provider: 'local',
- refreshUser: mocks.refreshUser,
user: {
email: 'reader@example.com',
name: 'Reader',
@@ -20,51 +17,33 @@ vi.mock('@holo-js/auth/next/client', () => ({
}),
}))
-vi.mock('next/navigation', () => ({
- useRouter: () => ({
- replace: mocks.replace,
- }),
+vi.mock('../app/logout/actions.ts', () => ({
+ logoutAction: mocks.logoutAction,
}))
vi.mock('next/link', () => ({
default: ({ children, href, ...props }) => jsx('a', { ...props, href, children }),
}))
-const originalFetch = globalThis.fetch
-const originalConsoleWarn = console.warn
-
const { AuthNav } = await import('../app/auth-nav.tsx')
describe('auth nav', () => {
beforeEach(() => {
vi.clearAllMocks()
- globalThis.fetch = mocks.fetch
- console.warn = vi.fn()
- })
-
- afterEach(() => {
- globalThis.fetch = originalFetch
- console.warn = originalConsoleWarn
})
- it('navigates home after logout even when auth refresh fails', async () => {
- const refreshError = new Error('refresh failed')
- mocks.fetch.mockResolvedValue(new Response(null, { status: 204 }))
- mocks.refreshUser.mockRejectedValue(refreshError)
-
+ it('renders logout as a native server action form', async () => {
let renderer
await act(async () => {
renderer = create(jsx(AuthNav, {}))
})
- await act(async () => {
- await renderer.root.findByType('button').props.onClick()
- })
+ const form = renderer.root.findByType('form')
+ const button = renderer.root.findByType('button')
- expect(mocks.fetch).toHaveBeenCalledWith('/api/logout', { method: 'POST' })
- expect(mocks.refreshUser).toHaveBeenCalledTimes(1)
- expect(console.warn).toHaveBeenCalledWith('Auth refresh failed after logout.', refreshError)
- expect(mocks.replace).toHaveBeenCalledWith('/')
+ expect(form.props.action).toBe(mocks.logoutAction)
+ expect(button.props.type).toBe('submit')
+ expect(button.props.children).toBe('Logout')
await act(async () => {
renderer.unmount()
diff --git a/apps/blog-next/tests/login-page.test.mjs b/apps/blog-next/tests/login-page.test.mjs
new file mode 100644
index 00000000..8abbec6e
--- /dev/null
+++ b/apps/blog-next/tests/login-page.test.mjs
@@ -0,0 +1,118 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+class RedirectSignal extends Error {
+ constructor(url) {
+ super(`Redirected to ${url}`)
+ this.url = url
+ }
+}
+
+const mocks = vi.hoisted(() => ({
+ login: vi.fn(),
+ revalidatePath: vi.fn(),
+ redirect: vi.fn(),
+ validate: vi.fn(),
+}))
+
+vi.mock('@holo-js/auth', () => ({
+ login: mocks.login,
+}))
+
+vi.mock('@holo-js/forms', () => ({
+ validate: mocks.validate,
+}))
+
+vi.mock('next/cache', () => ({
+ revalidatePath: mocks.revalidatePath,
+}))
+
+vi.mock('next/navigation', () => ({
+ redirect: mocks.redirect,
+}))
+
+vi.mock('@/lib/schemas/auth', () => ({
+ loginForm: {},
+}))
+
+const { loginAction } = await import('../app/login/actions.ts')
+
+function createValidSubmission(data) {
+ return {
+ valid: true,
+ data,
+ }
+}
+
+function createInvalidSubmission(payload) {
+ return {
+ valid: false,
+ fail: vi.fn(() => payload),
+ }
+}
+
+describe('login action', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('uses the shared forms validate API before the Next redirect', async () => {
+ const formData = new FormData()
+ formData.set('email', 'editor@example.com')
+ formData.set('password', 'secret-secret')
+ mocks.validate.mockResolvedValue(createValidSubmission({
+ email: 'editor@example.com',
+ password: 'secret-secret',
+ remember: false,
+ }))
+ mocks.login.mockResolvedValue({
+ data: {
+ emailVerificationRequired: false,
+ user: {
+ id: 'user-1',
+ email: 'editor@example.com',
+ },
+ },
+ error: null,
+ })
+ mocks.redirect.mockImplementation((url) => {
+ throw new RedirectSignal(url)
+ })
+
+ await expect(loginAction(formData)).rejects.toMatchObject({
+ url: '/admin',
+ })
+
+ expect(mocks.validate).toHaveBeenCalledWith(formData, {}, {
+ csrf: true,
+ throttle: 'login',
+ })
+ expect(mocks.login).toHaveBeenCalledWith({
+ email: 'editor@example.com',
+ password: 'secret-secret',
+ remember: false,
+ })
+ expect(mocks.revalidatePath).toHaveBeenCalledWith('/', 'layout')
+ expect(mocks.redirect).toHaveBeenCalledWith('/admin')
+ })
+
+ it('returns validation failures without logging in', async () => {
+ const failure = {
+ ok: false,
+ status: 422,
+ valid: false,
+ values: {
+ email: '',
+ },
+ errors: {
+ email: ['Email is required.'],
+ },
+ }
+ mocks.validate.mockResolvedValue(createInvalidSubmission(failure))
+
+ await expect(loginAction(new FormData())).resolves.toBe(failure)
+
+ expect(mocks.login).not.toHaveBeenCalled()
+ expect(mocks.revalidatePath).not.toHaveBeenCalled()
+ expect(mocks.redirect).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/blog-next/tests/logout-actions.test.mjs b/apps/blog-next/tests/logout-actions.test.mjs
new file mode 100644
index 00000000..b2ab45e1
--- /dev/null
+++ b/apps/blog-next/tests/logout-actions.test.mjs
@@ -0,0 +1,45 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ logout: vi.fn(),
+ redirect: vi.fn((location) => {
+ const error = new Error('NEXT_REDIRECT')
+ error.location = location
+ throw error
+ }),
+ revalidatePath: vi.fn(),
+}))
+
+vi.mock('@holo-js/auth', () => ({
+ logout: mocks.logout,
+}))
+
+vi.mock('next/cache', () => ({
+ revalidatePath: mocks.revalidatePath,
+}))
+
+vi.mock('next/navigation', () => ({
+ redirect: mocks.redirect,
+}))
+
+const { logoutAction } = await import('../app/logout/actions.ts')
+
+describe('logoutAction', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('logs out, revalidates the layout, and redirects home', async () => {
+ mocks.logout.mockResolvedValue({
+ authenticated: false,
+ })
+
+ await expect(logoutAction()).rejects.toMatchObject({
+ location: '/',
+ })
+
+ expect(mocks.logout).toHaveBeenCalledTimes(1)
+ expect(mocks.revalidatePath).toHaveBeenCalledWith('/', 'layout')
+ expect(mocks.redirect).toHaveBeenCalledWith('/')
+ })
+})
diff --git a/apps/blog-next/tests/register-page.test.mjs b/apps/blog-next/tests/register-page.test.mjs
index f61c6b66..7f77631a 100644
--- a/apps/blog-next/tests/register-page.test.mjs
+++ b/apps/blog-next/tests/register-page.test.mjs
@@ -1,14 +1,18 @@
import assert from 'node:assert/strict'
import { jsx } from 'react/jsx-runtime'
import { act, create } from 'react-test-renderer'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
loginUsing: vi.fn(),
- fetch: vi.fn(),
+ redirect: vi.fn((location) => {
+ const error = new Error('NEXT_REDIRECT')
+ error.location = location
+ throw error
+ }),
register: vi.fn(),
registerForm: Symbol('registerForm'),
- replace: vi.fn(),
+ revalidatePath: vi.fn(),
useForm: vi.fn(),
validate: vi.fn(),
}))
@@ -26,10 +30,12 @@ vi.mock('@holo-js/forms', () => ({
validate: mocks.validate,
}))
+vi.mock('next/cache', () => ({
+ revalidatePath: mocks.revalidatePath,
+}))
+
vi.mock('next/navigation', () => ({
- useRouter: () => ({
- replace: mocks.replace,
- }),
+ redirect: mocks.redirect,
}))
vi.mock('next/link', () => ({
@@ -40,10 +46,8 @@ vi.mock('@/lib/schemas/auth', () => ({
registerForm: mocks.registerForm,
}))
-const originalFetch = globalThis.fetch
-
const { default: RegisterPage } = await import('../app/register/page.tsx')
-const registerRoute = await import('../app/api/register/route.ts')
+const { registerAction } = await import('../app/register/actions.ts')
function createFormState(submit) {
return {
@@ -81,84 +85,65 @@ function createFormState(submit) {
}
}
-async function renderPageWithRedirect(redirectTo = '/login') {
- mocks.fetch.mockResolvedValue(new Response(JSON.stringify({
- ok: true,
- data: {
- redirectTo,
- },
- })))
- mocks.useForm.mockImplementation((_schema, options) => createFormState(vi.fn(async () => {
- const formData = new FormData()
- formData.set('name', 'Reader')
- formData.set('email', 'reader@example.com')
- formData.set('password', 'password123')
- formData.set('passwordConfirmation', 'password123')
-
- return options.submitter({ formData })
- })))
-
- let renderer
- await act(async () => {
- renderer = create(jsx(RegisterPage, {}))
- })
-
- assert.ok(renderer, 'Expected register page to render.')
- return renderer
-}
-
describe('register page', () => {
beforeEach(() => {
vi.clearAllMocks()
- globalThis.fetch = mocks.fetch
})
- afterEach(() => {
- globalThis.fetch = originalFetch
- })
-
- it('navigates to same-app redirect targets after successful registration', async () => {
- const renderer = await renderPageWithRedirect('/login')
-
- await act(async () => {
- renderer.root.findByType('form').props.onSubmit({
- preventDefault: vi.fn(),
- })
- })
+ it('submits through the register server action', async () => {
+ const failure = {
+ ok: false,
+ status: 422,
+ errors: {
+ email: ['Enter a valid email address.'],
+ },
+ }
+ const submission = {
+ valid: false,
+ fail: vi.fn(() => failure),
+ }
+ mocks.validate.mockResolvedValue(submission)
+ mocks.useForm.mockImplementation((_schema, options) => createFormState(vi.fn(async () => {
+ const formData = new FormData()
+ formData.set('name', 'Reader')
+ formData.set('email', 'bad')
+ formData.set('password', 'password123')
+ formData.set('passwordConfirmation', 'password123')
- expect(mocks.fetch).toHaveBeenCalledWith('/api/register', {
- method: 'POST',
- body: expect.any(FormData),
- })
- expect(mocks.useForm).toHaveBeenCalledWith(mocks.registerForm, expect.objectContaining({
- csrf: true,
- }))
- expect(mocks.replace).toHaveBeenCalledWith('/login')
+ return await options.submitter({ formData })
+ })))
+ let renderer
await act(async () => {
- renderer.unmount()
+ renderer = create(jsx(RegisterPage, {}))
})
- })
- it('ignores response-provided register redirect targets', async () => {
- const renderer = await renderPageWithRedirect('https://evil.test/login')
+ assert.ok(renderer, 'Expected register page to render.')
await act(async () => {
- renderer.root.findByType('form').props.onSubmit({
+ await renderer.root.findByType('form').props.onSubmit({
preventDefault: vi.fn(),
})
})
- expect(mocks.replace).toHaveBeenCalledWith('/login')
+ expect(mocks.useForm).toHaveBeenCalledWith(mocks.registerForm, expect.objectContaining({
+ csrf: true,
+ validateOn: 'blur',
+ }))
+ expect(mocks.validate).toHaveBeenCalledWith(expect.any(FormData), mocks.registerForm, {
+ csrf: true,
+ throttle: 'register',
+ })
+ expect(mocks.register).not.toHaveBeenCalled()
+ expect(mocks.redirect).not.toHaveBeenCalled()
await act(async () => {
renderer.unmount()
})
})
-
})
-describe('POST /api/register', () => {
+describe('registerAction', () => {
beforeEach(() => {
vi.clearAllMocks()
})
@@ -175,17 +160,11 @@ describe('POST /api/register', () => {
valid: false,
fail: vi.fn(() => failure),
}
- const request = new Request('http://localhost/api/register', {
- method: 'POST',
- })
mocks.validate.mockResolvedValue(submission)
- const response = await registerRoute.POST(request)
-
- expect(response.status).toBe(422)
- await expect(response.json()).resolves.toEqual(failure)
- expect(mocks.validate).toHaveBeenCalledWith(request, mocks.registerForm, {
+ await expect(registerAction(new FormData())).resolves.toBe(failure)
+ expect(mocks.validate).toHaveBeenCalledWith(expect.any(FormData), mocks.registerForm, {
csrf: true,
throttle: 'register',
})
@@ -193,15 +172,46 @@ describe('POST /api/register', () => {
expect(mocks.loginUsing).not.toHaveBeenCalled()
})
- it('keeps the verified registration success redirect unchanged', async () => {
+ it('returns registration failures without starting a session', async () => {
+ const failure = {
+ ok: false,
+ status: 422,
+ errors: {
+ email: ['The email has already been taken.'],
+ },
+ }
+ const submission = {
+ valid: true,
+ data: {
+ name: 'Reader',
+ email: 'reader@example.com',
+ password: 'password123',
+ passwordConfirmation: 'password123',
+ },
+ fail: vi.fn(() => failure),
+ }
+ mocks.validate.mockResolvedValue(submission)
+ mocks.register.mockResolvedValue({
+ data: null,
+ error: {
+ status: 422,
+ fields: {
+ email: ['The email has already been taken.'],
+ },
+ },
+ })
+
+ await expect(registerAction(new FormData())).resolves.toBe(failure)
+ expect(mocks.register).toHaveBeenCalledWith(submission.data)
+ expect(mocks.loginUsing).not.toHaveBeenCalled()
+ expect(mocks.redirect).not.toHaveBeenCalled()
+ })
+
+ it('uses the native Next redirect after verified registration', async () => {
const created = {
id: 7,
email: 'reader@example.com',
}
- const session = {
- emailVerificationRequired: false,
- user: created,
- }
const submission = {
valid: true,
data: {
@@ -211,34 +221,58 @@ describe('POST /api/register', () => {
passwordConfirmation: 'password123',
},
fail: vi.fn(),
- success: vi.fn((data, status) => ({
- ok: true,
- status,
- data,
- })),
}
mocks.validate.mockResolvedValue(submission)
mocks.register.mockResolvedValue({
data: created,
error: null,
})
- mocks.loginUsing.mockResolvedValue(session)
+ mocks.loginUsing.mockResolvedValue({
+ emailVerificationRequired: false,
+ user: created,
+ })
- const response = await registerRoute.POST(new Request('http://localhost/api/register', {
- method: 'POST',
- }))
+ await expect(registerAction(new FormData())).rejects.toMatchObject({
+ location: '/admin',
+ })
+
+ expect(mocks.register).toHaveBeenCalledWith(submission.data)
+ expect(mocks.loginUsing).toHaveBeenCalledWith(created)
+ expect(mocks.revalidatePath).toHaveBeenCalledWith('/', 'layout')
+ expect(mocks.redirect).toHaveBeenCalledWith('/admin')
+ })
- expect(response.status).toBe(201)
- await expect(response.json()).resolves.toEqual({
- ok: true,
- status: 201,
+ it('redirects to email verification when the new session requires it', async () => {
+ const created = {
+ id: 7,
+ email: 'reader@example.com',
+ }
+ const submission = {
+ valid: true,
data: {
- message: 'Account created and signed in successfully.',
- redirectTo: '/admin',
- user: created,
+ name: 'Reader',
+ email: 'reader@example.com',
+ password: 'password123',
+ passwordConfirmation: 'password123',
},
+ fail: vi.fn(),
+ }
+ mocks.validate.mockResolvedValue(submission)
+ mocks.register.mockResolvedValue({
+ data: created,
+ error: null,
})
- expect(mocks.register).toHaveBeenCalledWith(submission.data)
- expect(mocks.loginUsing).toHaveBeenCalledWith(created)
+ mocks.loginUsing.mockResolvedValue({
+ emailVerificationRequired: true,
+ emailVerificationRoute: '/verify-email',
+ user: created,
+ })
+
+ await expect(registerAction(new FormData())).rejects.toMatchObject({
+ location: '/verify-email',
+ })
+
+ expect(mocks.revalidatePath).toHaveBeenCalledWith('/', 'layout')
+ expect(mocks.redirect).toHaveBeenCalledWith('/verify-email')
})
})
diff --git a/apps/blog-next/tests/run.mjs b/apps/blog-next/tests/run.mjs
index f562c94a..7f5ced85 100644
--- a/apps/blog-next/tests/run.mjs
+++ b/apps/blog-next/tests/run.mjs
@@ -75,17 +75,40 @@ function containsJsxNode(node, tagName) {
return ts.forEachChild(node, child => containsJsxNode(child, tagName)) === true
}
-function containsRouterReplaceHome(node) {
+function containsLogoutActionForm(node) {
if (
- ts.isCallExpression(node)
- && ts.isPropertyAccessExpression(node.expression)
- && node.expression.name.text === 'replace'
- && node.arguments.some(argument => ts.isStringLiteral(argument) && argument.text === '/')
+ ts.isJsxSelfClosingElement(node)
+ && getJsxTagName(node.tagName) === 'form'
+ && node.attributes.properties.some(attribute => (
+ ts.isJsxAttribute(attribute)
+ && attribute.name.text === 'action'
+ && attribute.initializer
+ && ts.isJsxExpression(attribute.initializer)
+ && attribute.initializer.expression
+ && ts.isIdentifier(attribute.initializer.expression)
+ && attribute.initializer.expression.text === 'logoutAction'
+ ))
) {
return true
}
- return ts.forEachChild(node, containsRouterReplaceHome) === true
+ if (
+ ts.isJsxElement(node)
+ && getJsxTagName(node.openingElement.tagName) === 'form'
+ && node.openingElement.attributes.properties.some(attribute => (
+ ts.isJsxAttribute(attribute)
+ && attribute.name.text === 'action'
+ && attribute.initializer
+ && ts.isJsxExpression(attribute.initializer)
+ && attribute.initializer.expression
+ && ts.isIdentifier(attribute.initializer.expression)
+ && attribute.initializer.expression.text === 'logoutAction'
+ ))
+ ) {
+ return true
+ }
+
+ return ts.forEachChild(node, containsLogoutActionForm) === true
}
async function assertRootLayoutSharesAuthProviderState() {
@@ -109,8 +132,8 @@ async function assertHeaderLogoutRedirectsHome() {
const sourceFile = ts.createSourceFile('auth-nav.tsx', authNavSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
assert.ok(
- containsRouterReplaceHome(sourceFile),
- 'Expected header logout to redirect home after clearing the session.',
+ containsLogoutActionForm(sourceFile),
+ 'Expected header logout to use the logout server action form.',
)
}
@@ -302,7 +325,7 @@ try {
await rm(join(cwd, '.next'), { recursive: true, force: true })
await assertRootLayoutSharesAuthProviderState()
await assertHeaderLogoutRedirectsHome()
- await run('npx', ['vitest', '--run', 'tests/api-v1-routes.test.mjs', 'tests/auth-nav.test.mjs', 'tests/current-auth-route.test.mjs', 'tests/forgot-password-route.test.mjs', 'tests/hosted-logout-routes.test.mjs', 'tests/package-checks.test.mjs', 'tests/register-page.test.mjs', 'tests/reset-password-page.test.mjs', 'tests/reset-password-route.test.mjs', 'tests/social-auth-routes.test.mjs', 'tests/super-admin-logout-button.test.mjs', 'tests/super-admin-login-page.test.mjs', 'tests/super-admin-login-route.test.mjs', 'tests/verify-email-page.test.mjs', '--reporter=json'])
+ await run('npx', ['vitest', '--run', 'tests/api-v1-routes.test.mjs', 'tests/auth-nav.test.mjs', 'tests/current-auth-route.test.mjs', 'tests/forgot-password-route.test.mjs', 'tests/hosted-logout-routes.test.mjs', 'tests/login-page.test.mjs', 'tests/logout-actions.test.mjs', 'tests/package-checks.test.mjs', 'tests/register-page.test.mjs', 'tests/reset-password-page.test.mjs', 'tests/reset-password-route.test.mjs', 'tests/social-auth-routes.test.mjs', 'tests/super-admin-logout-button.test.mjs', 'tests/super-admin-login-page.test.mjs', 'tests/super-admin-login-route.test.mjs', 'tests/verify-email-page.test.mjs', '--reporter=json'])
await run('bun', ['run', 'prepare'])
await run('bun', ['x', 'holo', 'migrate:fresh', '--seed'])
await run('npx', ['tsx', 'tests/blog-logic.mjs'])
diff --git a/apps/blog-next/tests/super-admin-login-page.test.mjs b/apps/blog-next/tests/super-admin-login-page.test.mjs
index 09b24a0e..b5c49325 100644
--- a/apps/blog-next/tests/super-admin-login-page.test.mjs
+++ b/apps/blog-next/tests/super-admin-login-page.test.mjs
@@ -3,34 +3,56 @@ import { act, create } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
- refreshUser: vi.fn(),
- replace: vi.fn(),
+ guardLogin: vi.fn(),
+ redirect: vi.fn((location) => {
+ const error = new Error('NEXT_REDIRECT')
+ error.location = location
+ throw error
+ }),
+ revalidatePath: vi.fn(),
+ superAdminLoginAction: vi.fn(),
useForm: vi.fn(),
+ validate: vi.fn(),
}))
vi.mock('@holo-js/adapter-next/client', () => ({
useForm: mocks.useForm,
}))
-vi.mock('@holo-js/auth/next/client', () => ({
- useAuth: () => ({
- refreshUser: mocks.refreshUser,
- }),
+vi.mock('@holo-js/auth', () => ({
+ default: {
+ guard: vi.fn(() => ({
+ login: mocks.guardLogin,
+ })),
+ },
+}))
+
+vi.mock('@holo-js/forms', () => ({
+ validate: mocks.validate,
+}))
+
+vi.mock('next/cache', () => ({
+ revalidatePath: mocks.revalidatePath,
}))
vi.mock('next/navigation', () => ({
- useRouter: () => ({
- replace: mocks.replace,
- }),
+ redirect: mocks.redirect,
}))
vi.mock('@/lib/schemas/auth', () => ({
- loginForm: {},
+ loginForm: Symbol('loginForm'),
+}))
+
+vi.mock('../app/super-admin/login/actions.ts', async (importOriginal) => ({
+ ...(await importOriginal()),
+ superAdminLoginAction: mocks.superAdminLoginAction,
}))
const { default: SuperAdminLoginPage } = await import('../app/super-admin/login/page.tsx')
+vi.doUnmock('../app/super-admin/login/actions.ts')
+const { superAdminLoginAction } = await import('../app/super-admin/login/actions.ts?actual')
-function createFormState(rootError) {
+function createFormState(rootError, submit = vi.fn()) {
return {
values: {
email: '',
@@ -55,7 +77,7 @@ function createFormState(rootError) {
first: vi.fn(field => field === '_root' ? rootError : undefined),
},
submitting: false,
- submit: vi.fn(),
+ submit,
lastSubmission: {
ok: false,
},
@@ -85,4 +107,156 @@ describe('super admin login page', () => {
renderer.unmount()
})
})
+
+ it('submits through the super admin login server action', async () => {
+ mocks.superAdminLoginAction.mockResolvedValue({
+ ok: false,
+ status: 422,
+ })
+ mocks.useForm.mockImplementation((_schema, options) => createFormState(undefined, vi.fn(async () => {
+ const formData = new FormData()
+ formData.set('email', 'admin@example.com')
+ formData.set('password', 'secret-secret')
+
+ return await options.submitter({ formData })
+ })))
+
+ let renderer
+ await act(async () => {
+ renderer = create(jsx(SuperAdminLoginPage, {}))
+ })
+
+ await act(async () => {
+ await renderer.root.findByType('form').props.onSubmit({
+ preventDefault: vi.fn(),
+ })
+ })
+
+ expect(mocks.superAdminLoginAction).toHaveBeenCalledWith(expect.any(FormData))
+
+ await act(async () => {
+ renderer.unmount()
+ })
+ })
+})
+
+describe('superAdminLoginAction', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('returns validation failures before logging in', async () => {
+ const failure = {
+ ok: false,
+ status: 422,
+ errors: {
+ email: ['Enter a valid email address.'],
+ },
+ }
+ const submission = {
+ valid: false,
+ fail: vi.fn(() => failure),
+ }
+
+ mocks.validate.mockResolvedValue(submission)
+
+ await expect(superAdminLoginAction(new FormData())).resolves.toBe(failure)
+ expect(mocks.validate).toHaveBeenCalledWith(expect.any(FormData), expect.anything(), {
+ throttle: 'login',
+ })
+ expect(mocks.guardLogin).not.toHaveBeenCalled()
+ })
+
+ it('returns auth failures without redirecting', async () => {
+ const failure = {
+ ok: false,
+ status: 401,
+ errors: {
+ _root: ['These credentials do not match our records.'],
+ },
+ }
+ const submission = {
+ valid: true,
+ data: {
+ email: 'admin@example.com',
+ password: 'bad-password',
+ remember: false,
+ },
+ fail: vi.fn(() => failure),
+ }
+ mocks.validate.mockResolvedValue(submission)
+ mocks.guardLogin.mockResolvedValue({
+ data: null,
+ error: {
+ status: 401,
+ fields: {
+ _root: ['These credentials do not match our records.'],
+ },
+ },
+ })
+
+ await expect(superAdminLoginAction(new FormData())).resolves.toBe(failure)
+ expect(mocks.guardLogin).toHaveBeenCalledWith(submission.data)
+ expect(mocks.redirect).not.toHaveBeenCalled()
+ })
+
+ it('uses the native Next redirect after super admin login', async () => {
+ const submission = {
+ valid: true,
+ data: {
+ email: 'admin@example.com',
+ password: 'secret-secret',
+ remember: false,
+ },
+ fail: vi.fn(),
+ }
+ mocks.validate.mockResolvedValue(submission)
+ mocks.guardLogin.mockResolvedValue({
+ data: {
+ emailVerificationRequired: false,
+ user: {
+ email: 'admin@example.com',
+ },
+ },
+ error: null,
+ })
+
+ await expect(superAdminLoginAction(new FormData())).rejects.toMatchObject({
+ location: '/super-admin',
+ })
+
+ expect(mocks.guardLogin).toHaveBeenCalledWith(submission.data)
+ expect(mocks.revalidatePath).toHaveBeenCalledWith('/', 'layout')
+ expect(mocks.redirect).toHaveBeenCalledWith('/super-admin')
+ })
+
+ it('redirects to email verification when the admin session requires it', async () => {
+ const submission = {
+ valid: true,
+ data: {
+ email: 'admin@example.com',
+ password: 'secret-secret',
+ remember: false,
+ },
+ fail: vi.fn(),
+ }
+ mocks.validate.mockResolvedValue(submission)
+ mocks.guardLogin.mockResolvedValue({
+ data: {
+ emailVerificationRequired: true,
+ emailVerificationRoute: '/verify-email',
+ user: {
+ email: 'admin@example.com',
+ },
+ },
+ error: null,
+ })
+
+ await expect(superAdminLoginAction(new FormData())).rejects.toMatchObject({
+ location: '/verify-email',
+ })
+
+ expect(mocks.revalidatePath).toHaveBeenCalledWith('/', 'layout')
+ expect(mocks.redirect).toHaveBeenCalledWith('/verify-email')
+ })
})
diff --git a/apps/blog-next/tests/super-admin-logout-button.test.mjs b/apps/blog-next/tests/super-admin-logout-button.test.mjs
index c2f165d0..9f45246b 100644
--- a/apps/blog-next/tests/super-admin-logout-button.test.mjs
+++ b/apps/blog-next/tests/super-admin-logout-button.test.mjs
@@ -1,139 +1,83 @@
import { jsx } from 'react/jsx-runtime'
import { act, create } from 'react-test-renderer'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
- fetch: vi.fn(),
- refreshUser: vi.fn(),
- replace: vi.fn(),
+ guardLogout: vi.fn(),
+ redirect: vi.fn((location) => {
+ const error = new Error('NEXT_REDIRECT')
+ error.location = location
+ throw error
+ }),
+ revalidatePath: vi.fn(),
+ superAdminLogoutAction: vi.fn(),
}))
-vi.mock('@holo-js/auth/next/client', () => ({
- useAuth: () => ({
- refreshUser: mocks.refreshUser,
- }),
+vi.mock('@holo-js/auth', () => ({
+ default: {
+ guard: vi.fn(() => ({
+ logout: mocks.guardLogout,
+ })),
+ },
+}))
+
+vi.mock('next/cache', () => ({
+ revalidatePath: mocks.revalidatePath,
}))
vi.mock('next/navigation', () => ({
- useRouter: () => ({
- replace: mocks.replace,
- }),
+ redirect: mocks.redirect,
}))
-const originalFetch = globalThis.fetch
-const originalConsoleWarn = console.warn
+vi.mock('../app/super-admin/logout/actions.ts', async (importOriginal) => ({
+ ...(await importOriginal()),
+ superAdminLogoutAction: mocks.superAdminLogoutAction,
+}))
const { SuperAdminLogoutButton } = await import('../app/super-admin/logout-button.tsx')
-
-function createDeferred() {
- let resolvePromise = () => {}
- const promise = new Promise(resolve => {
- resolvePromise = resolve
- })
-
- return {
- promise,
- resolve(value) {
- resolvePromise(value)
- },
- }
-}
+vi.doUnmock('../app/super-admin/logout/actions.ts')
+const { superAdminLogoutAction } = await import('../app/super-admin/logout/actions.ts?actual')
describe('super admin logout button', () => {
beforeEach(() => {
vi.clearAllMocks()
- globalThis.fetch = mocks.fetch
- console.warn = vi.fn()
})
- afterEach(() => {
- globalThis.fetch = originalFetch
- console.warn = originalConsoleWarn
- })
-
- it('navigates to login after logout even when auth refresh fails', async () => {
- mocks.fetch.mockResolvedValue(new Response(null, { status: 204 }))
- mocks.refreshUser.mockRejectedValue(new Error('refresh failed'))
-
+ it('renders logout as a native server action form', async () => {
let renderer
await act(async () => {
renderer = create(jsx(SuperAdminLogoutButton, {}))
})
+ const form = renderer.root.findByType('form')
const button = renderer.root.findByType('button')
- await act(async () => {
- await button.props.onClick()
- })
- expect(mocks.fetch).toHaveBeenCalledWith('/api/super-admin/logout', { method: 'POST' })
- expect(mocks.refreshUser).toHaveBeenCalledTimes(1)
- expect(console.warn).toHaveBeenCalledWith(
- 'Super admin auth refresh failed after logout.',
- expect.any(Error),
- )
- expect(mocks.replace).toHaveBeenCalledWith('/super-admin/login')
+ expect(form.props.action).toBe(mocks.superAdminLogoutAction)
+ expect(button.props.type).toBe('submit')
+ expect(button.props.children).toBe('Sign out of super admin')
await act(async () => {
renderer.unmount()
})
})
+})
- it('ignores duplicate logout clicks while a request is in flight', async () => {
- const logoutResponse = createDeferred()
- mocks.fetch.mockReturnValue(logoutResponse.promise)
-
- let renderer
- await act(async () => {
- renderer = create(jsx(SuperAdminLogoutButton, {}))
- })
-
- const firstClick = renderer.root.findByType('button').props.onClick()
- await act(async () => {
- await Promise.resolve()
- })
-
- const loadingButton = renderer.root.findByType('button')
- expect(loadingButton.props.disabled).toBe(true)
- expect(loadingButton.props.children).toBe('Signing out...')
-
- await act(async () => {
- await loadingButton.props.onClick()
- })
-
- expect(mocks.fetch).toHaveBeenCalledTimes(1)
-
- logoutResponse.resolve(new Response(null, { status: 500 }))
- await act(async () => {
- await firstClick
- })
-
- expect(console.warn).toHaveBeenCalledWith('Super admin logout failed.', { status: 500 })
- expect(mocks.replace).not.toHaveBeenCalled()
-
- await act(async () => {
- renderer.unmount()
- })
+describe('superAdminLogoutAction', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
})
- it('keeps users on the page when the logout request fails before clearing the session', async () => {
- const logoutError = new Error('network failed')
- mocks.fetch.mockRejectedValue(logoutError)
-
- let renderer
- await act(async () => {
- renderer = create(jsx(SuperAdminLogoutButton, {}))
+ it('logs out the admin guard and redirects to super admin login', async () => {
+ mocks.guardLogout.mockResolvedValue({
+ authenticated: false,
})
- await act(async () => {
- await renderer.root.findByType('button').props.onClick()
+ await expect(superAdminLogoutAction()).rejects.toMatchObject({
+ location: '/super-admin/login',
})
- expect(console.warn).toHaveBeenCalledWith('Super admin logout failed.', logoutError)
- expect(mocks.refreshUser).not.toHaveBeenCalled()
- expect(mocks.replace).not.toHaveBeenCalled()
-
- await act(async () => {
- renderer.unmount()
- })
+ expect(mocks.guardLogout).toHaveBeenCalledTimes(1)
+ expect(mocks.revalidatePath).toHaveBeenCalledWith('/', 'layout')
+ expect(mocks.redirect).toHaveBeenCalledWith('/super-admin/login')
})
})
diff --git a/apps/blog-sveltekit/src/routes/+layout.server.ts b/apps/blog-sveltekit/src/routes/+layout.server.ts
index b6140934..e528b4ad 100644
--- a/apps/blog-sveltekit/src/routes/+layout.server.ts
+++ b/apps/blog-sveltekit/src/routes/+layout.server.ts
@@ -1,9 +1,12 @@
import { auth } from '@holo-js/auth/sveltekit/server'
+import { csrf } from '@holo-js/security'
+import type { LayoutServerLoad } from './$types'
-export async function load() {
+export const load = (async ({ request }) => {
const currentAuth = await auth()
return {
auth: currentAuth,
+ csrf: await csrf.field(request),
}
-}
+}) satisfies LayoutServerLoad
diff --git a/apps/blog-sveltekit/src/routes/+layout.svelte b/apps/blog-sveltekit/src/routes/+layout.svelte
index 37c3750e..cdb138b9 100644
--- a/apps/blog-sveltekit/src/routes/+layout.svelte
+++ b/apps/blog-sveltekit/src/routes/+layout.svelte
@@ -1,11 +1,9 @@
@@ -57,7 +24,9 @@
{#if auth.authenticated}
{displayName}
{#if !usesHostedLogout}
-
+
{/if}
{#if auth.provider === 'workos'}
-
- {#if form.lastSubmission?.ok === true}
-
- {/if}
-
Create account
Forgot password?
@@ -101,6 +90,5 @@
.social-links a { color: #e5e7eb; text-decoration: none; }
.remember, .links { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
.error { color: #fca5a5; }
- .success { color: #86efac; }
- .success a, .links a { color: #7dd3fc; text-decoration: none; }
+ .links a { color: #7dd3fc; text-decoration: none; }
diff --git a/apps/blog-sveltekit/src/routes/logout/+server.ts b/apps/blog-sveltekit/src/routes/logout/+server.ts
new file mode 100644
index 00000000..987aee1d
--- /dev/null
+++ b/apps/blog-sveltekit/src/routes/logout/+server.ts
@@ -0,0 +1,7 @@
+import { redirect } from '@sveltejs/kit'
+import { logout } from '@holo-js/auth'
+
+export async function POST() {
+ await logout()
+ redirect(303, '/')
+}
diff --git a/apps/blog-sveltekit/src/routes/register/+page.server.ts b/apps/blog-sveltekit/src/routes/register/+page.server.ts
new file mode 100644
index 00000000..cce5ee30
--- /dev/null
+++ b/apps/blog-sveltekit/src/routes/register/+page.server.ts
@@ -0,0 +1,35 @@
+import { fail, redirect } from '@sveltejs/kit'
+import { loginUsing, register } from '@holo-js/auth'
+import { validate } from '@holo-js/forms'
+
+import { registerForm } from '$lib/schemas/auth'
+import type { Actions } from './$types'
+
+export const actions = {
+ default: async ({ request }) => {
+ const submission = await validate(request, registerForm, {
+ csrf: true,
+ throttle: 'register',
+ })
+
+ if (!submission.valid) {
+ const failure = submission.fail()
+ return fail(failure.status, failure)
+ }
+
+ const { data: created, error } = await register(submission.data)
+ if (error) {
+ const failure = submission.fail({
+ status: error.status,
+ errors: error.fields,
+ })
+
+ return fail(failure.status, failure)
+ }
+
+ const session = await loginUsing(created)
+ redirect(303, session.emailVerificationRequired
+ ? session.emailVerificationRoute ?? '/verify-email'
+ : '/admin')
+ },
+} satisfies Actions
diff --git a/apps/blog-sveltekit/src/routes/register/+page.svelte b/apps/blog-sveltekit/src/routes/register/+page.svelte
index 4b9a9d6a..0ff81ea2 100644
--- a/apps/blog-sveltekit/src/routes/register/+page.svelte
+++ b/apps/blog-sveltekit/src/routes/register/+page.svelte
@@ -1,25 +1,15 @@
@@ -28,17 +18,23 @@
Create a local user account and verify the email address before signing in.
-
- {#if form.lastSubmission?.ok === true}
-
-
Account created. Check your inbox to verify your email address.
-
Return to sign in
-
- {/if}
-
Already have an account?
Register with WorkOS
Register with Clerk
@@ -107,6 +96,5 @@
.stack, .field { display: grid; gap: 0.35rem; }
.stack { gap: 0.9rem; }
.error { color: #fca5a5; }
- .success { color: #86efac; display: grid; gap: 0.5rem; }
- .success a, .link { color: #7dd3fc; text-decoration: none; }
+ .link { color: #7dd3fc; text-decoration: none; }
diff --git a/apps/blog-sveltekit/src/routes/super-admin/+page.server.ts b/apps/blog-sveltekit/src/routes/super-admin/+page.server.ts
index b699ac7b..0d021c19 100644
--- a/apps/blog-sveltekit/src/routes/super-admin/+page.server.ts
+++ b/apps/blog-sveltekit/src/routes/super-admin/+page.server.ts
@@ -1,5 +1,7 @@
import { redirect } from '@sveltejs/kit'
+import authRuntime from '@holo-js/auth'
import { auth } from '@holo-js/auth/sveltekit/server'
+import type { Actions } from './$types'
export async function load() {
const currentAuth = await auth({ guard: 'admin' })
@@ -12,3 +14,10 @@ export async function load() {
admin: currentAuth.user,
}
}
+
+export const actions = {
+ default: async () => {
+ await authRuntime.guard('admin').logout()
+ redirect(303, '/super-admin/login')
+ },
+} satisfies Actions
diff --git a/apps/blog-sveltekit/src/routes/super-admin/+page.svelte b/apps/blog-sveltekit/src/routes/super-admin/+page.svelte
index 3057c09c..534568cf 100644
--- a/apps/blog-sveltekit/src/routes/super-admin/+page.svelte
+++ b/apps/blog-sveltekit/src/routes/super-admin/+page.svelte
@@ -1,37 +1,8 @@
@@ -39,9 +10,9 @@
Super Admin
Signed in as {displayName} through the admin guard.
-
+
diff --git a/apps/blog-sveltekit/src/routes/super-admin/login/+page.server.ts b/apps/blog-sveltekit/src/routes/super-admin/login/+page.server.ts
new file mode 100644
index 00000000..de0bc330
--- /dev/null
+++ b/apps/blog-sveltekit/src/routes/super-admin/login/+page.server.ts
@@ -0,0 +1,34 @@
+import { fail, redirect } from '@sveltejs/kit'
+import auth from '@holo-js/auth'
+import { validate } from '@holo-js/forms'
+
+import { loginForm } from '$lib/schemas/auth'
+import type { Actions } from './$types'
+
+export const actions = {
+ default: async ({ request }) => {
+ const submission = await validate(request, loginForm, {
+ csrf: true,
+ throttle: 'login',
+ })
+
+ if (!submission.valid) {
+ const failure = submission.fail()
+ return fail(failure.status, failure)
+ }
+
+ const { data: session, error } = await auth.guard('admin').login(submission.data)
+ if (error) {
+ const failure = submission.fail({
+ status: error.status,
+ errors: error.fields,
+ })
+
+ return fail(failure.status, failure)
+ }
+
+ redirect(303, session.emailVerificationRequired
+ ? session.emailVerificationRoute ?? '/verify-email'
+ : '/super-admin')
+ },
+} satisfies Actions
diff --git a/apps/blog-sveltekit/src/routes/super-admin/login/+page.svelte b/apps/blog-sveltekit/src/routes/super-admin/login/+page.svelte
index 79c4a154..972620af 100644
--- a/apps/blog-sveltekit/src/routes/super-admin/login/+page.svelte
+++ b/apps/blog-sveltekit/src/routes/super-admin/login/+page.svelte
@@ -1,21 +1,15 @@
@@ -24,18 +18,24 @@
Use a super admin account to access the super admin area.
-
-
- {#if form.lastSubmission?.ok === true}
-
-
Signed in as super admin.
-
- {/if}
diff --git a/apps/blog-sveltekit/tests/auth-page-actions.test.mjs b/apps/blog-sveltekit/tests/auth-page-actions.test.mjs
new file mode 100644
index 00000000..cd991c9c
--- /dev/null
+++ b/apps/blog-sveltekit/tests/auth-page-actions.test.mjs
@@ -0,0 +1,321 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ fail: vi.fn((status, data) => ({
+ ...data,
+ status,
+ })),
+ guardLogin: vi.fn(),
+ guardLogout: vi.fn(),
+ login: vi.fn(),
+ loginForm: Symbol('loginForm'),
+ loginUsing: vi.fn(),
+ logout: vi.fn(),
+ redirect: vi.fn((status, location) => {
+ const error = new Error('SVELTEKIT_REDIRECT')
+ error.status = status
+ error.location = location
+ throw error
+ }),
+ register: vi.fn(),
+ registerForm: Symbol('registerForm'),
+ validate: vi.fn(),
+}))
+
+vi.mock('@sveltejs/kit', () => ({
+ fail: mocks.fail,
+ redirect: mocks.redirect,
+}))
+
+vi.mock('@holo-js/auth', () => ({
+ default: {
+ guard: vi.fn(() => ({
+ login: mocks.guardLogin,
+ logout: mocks.guardLogout,
+ })),
+ },
+ login: mocks.login,
+ loginUsing: mocks.loginUsing,
+ logout: mocks.logout,
+ register: mocks.register,
+}))
+
+vi.mock('@holo-js/auth/sveltekit/server', () => ({
+ auth: vi.fn(async () => ({
+ authenticated: true,
+ user: {
+ email: 'super-admin@example.com',
+ name: 'Super Admin',
+ },
+ })),
+}))
+
+vi.mock('@holo-js/forms', () => ({
+ validate: mocks.validate,
+}))
+
+vi.mock('$lib/schemas/auth', () => ({
+ loginForm: mocks.loginForm,
+ registerForm: mocks.registerForm,
+}))
+
+const loginPage = await import('../src/routes/login/+page.server.ts')
+const logoutRoute = await import('../src/routes/logout/+server.ts')
+const registerPage = await import('../src/routes/register/+page.server.ts')
+const superAdminPage = await import('../src/routes/super-admin/+page.server.ts')
+const superAdminLoginPage = await import('../src/routes/super-admin/login/+page.server.ts')
+
+function createRequest(path = '/login') {
+ return new Request(`http://localhost${path}`, {
+ method: 'POST',
+ })
+}
+
+describe('SvelteKit login page action', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('returns form failures before logging in', async () => {
+ const failure = {
+ ok: false,
+ status: 422,
+ errors: {
+ email: ['Enter a valid email address.'],
+ },
+ }
+ const submission = {
+ valid: false,
+ fail: vi.fn(() => failure),
+ }
+ mocks.validate.mockResolvedValue(submission)
+
+ const response = await loginPage.actions.default({
+ request: createRequest('/login'),
+ })
+
+ expect(response.status).toBe(422)
+ expect(response).toEqual(failure)
+ expect(mocks.validate).toHaveBeenCalledWith(expect.any(Request), mocks.loginForm, {
+ csrf: true,
+ throttle: 'login',
+ })
+ expect(mocks.login).not.toHaveBeenCalled()
+ })
+
+ it('returns the login redirect target after successful login', async () => {
+ const submission = {
+ valid: true,
+ data: {
+ email: 'editor@example.com',
+ password: 'secret-secret',
+ remember: false,
+ },
+ fail: vi.fn(),
+ success: vi.fn((data, status = 200) => ({
+ ok: true,
+ status,
+ data,
+ })),
+ }
+ mocks.validate.mockResolvedValue(submission)
+ mocks.login.mockResolvedValue({
+ data: {
+ emailVerificationRequired: false,
+ user: {
+ email: 'editor@example.com',
+ },
+ },
+ error: null,
+ })
+
+ await expect(loginPage.actions.default({
+ request: createRequest('/login'),
+ })).rejects.toMatchObject({
+ status: 303,
+ location: '/admin',
+ })
+ expect(mocks.login).toHaveBeenCalledWith(submission.data)
+ })
+})
+
+describe('SvelteKit register page action', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('returns form failures before registering', async () => {
+ const failure = {
+ ok: false,
+ status: 422,
+ errors: {
+ email: ['Enter a valid email address.'],
+ },
+ }
+ const submission = {
+ valid: false,
+ fail: vi.fn(() => failure),
+ }
+ mocks.validate.mockResolvedValue(submission)
+
+ const response = await registerPage.actions.default({
+ request: createRequest('/register'),
+ })
+
+ expect(response.status).toBe(422)
+ expect(response).toEqual(failure)
+ expect(mocks.validate).toHaveBeenCalledWith(expect.any(Request), mocks.registerForm, {
+ csrf: true,
+ throttle: 'register',
+ })
+ expect(mocks.register).not.toHaveBeenCalled()
+ })
+
+ it('returns the registration redirect target after successful registration', async () => {
+ const created = {
+ email: 'reader@example.com',
+ }
+ const submission = {
+ valid: true,
+ data: {
+ name: 'Reader',
+ email: 'reader@example.com',
+ password: 'secret-secret',
+ passwordConfirmation: 'secret-secret',
+ },
+ fail: vi.fn(),
+ success: vi.fn((data, status = 200) => ({
+ ok: true,
+ status,
+ data,
+ })),
+ }
+ mocks.validate.mockResolvedValue(submission)
+ mocks.register.mockResolvedValue({
+ data: created,
+ error: null,
+ })
+ mocks.loginUsing.mockResolvedValue({
+ emailVerificationRequired: true,
+ emailVerificationRoute: '/verify-email?email=reader%40example.com',
+ user: created,
+ })
+
+ await expect(registerPage.actions.default({
+ request: createRequest('/register'),
+ })).rejects.toMatchObject({
+ status: 303,
+ location: '/verify-email?email=reader%40example.com',
+ })
+ expect(mocks.register).toHaveBeenCalledWith(submission.data)
+ expect(mocks.loginUsing).toHaveBeenCalledWith(created)
+ })
+})
+
+describe('SvelteKit logout route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('logs out and redirects from the server', async () => {
+ mocks.logout.mockResolvedValue({
+ authenticated: false,
+ })
+
+ await expect(logoutRoute.POST()).rejects.toMatchObject({
+ status: 303,
+ location: '/',
+ })
+
+ expect(mocks.logout).toHaveBeenCalledTimes(1)
+ expect(mocks.redirect).toHaveBeenCalledWith(303, '/')
+ })
+})
+
+describe('SvelteKit super admin page action', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('logs out the admin guard and redirects to super admin login', async () => {
+ mocks.guardLogout.mockResolvedValue({
+ authenticated: false,
+ })
+
+ await expect(superAdminPage.actions.default()).rejects.toMatchObject({
+ status: 303,
+ location: '/super-admin/login',
+ })
+
+ expect(mocks.guardLogout).toHaveBeenCalledTimes(1)
+ expect(mocks.redirect).toHaveBeenCalledWith(303, '/super-admin/login')
+ })
+})
+
+describe('SvelteKit super admin login page action', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('returns form failures before logging in the admin guard', async () => {
+ const failure = {
+ ok: false,
+ status: 422,
+ errors: {
+ email: ['Enter a valid email address.'],
+ },
+ }
+ const submission = {
+ valid: false,
+ fail: vi.fn(() => failure),
+ }
+ mocks.validate.mockResolvedValue(submission)
+
+ const response = await superAdminLoginPage.actions.default({
+ request: createRequest('/super-admin/login'),
+ })
+
+ expect(response.status).toBe(422)
+ expect(response).toEqual(failure)
+ expect(mocks.validate).toHaveBeenCalledWith(expect.any(Request), mocks.loginForm, {
+ csrf: true,
+ throttle: 'login',
+ })
+ expect(mocks.guardLogin).not.toHaveBeenCalled()
+ })
+
+ it('returns the super admin redirect target after login', async () => {
+ const submission = {
+ valid: true,
+ data: {
+ email: 'super-admin@example.com',
+ password: 'admin-secret',
+ remember: false,
+ },
+ fail: vi.fn(),
+ success: vi.fn((data, status = 200) => ({
+ ok: true,
+ status,
+ data,
+ })),
+ }
+ mocks.validate.mockResolvedValue(submission)
+ mocks.guardLogin.mockResolvedValue({
+ data: {
+ emailVerificationRequired: false,
+ user: {
+ email: 'super-admin@example.com',
+ },
+ },
+ error: null,
+ })
+
+ await expect(superAdminLoginPage.actions.default({
+ request: createRequest('/super-admin/login'),
+ })).rejects.toMatchObject({
+ status: 303,
+ location: '/super-admin',
+ })
+ expect(mocks.guardLogin).toHaveBeenCalledWith(submission.data)
+ })
+})
diff --git a/apps/blog-sveltekit/tests/blog-logic.mjs b/apps/blog-sveltekit/tests/blog-logic.mjs
index 52aa8f65..9f83199a 100644
--- a/apps/blog-sveltekit/tests/blog-logic.mjs
+++ b/apps/blog-sveltekit/tests/blog-logic.mjs
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'
-import { randomUUID } from 'node:crypto'
+import { createHmac, randomBytes, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { authRuntimeInternals, hashPassword, verifyPassword } from '@holo-js/auth'
@@ -16,7 +16,7 @@ import { actions as createTagPageActions } from '../src/routes/admin/tags/+page.
import { actions as updatePostPageActions } from '../src/routes/admin/posts/[id]/edit/+page.server.ts'
import { actions as createPostPageActions } from '../src/routes/admin/posts/new/+page.server.ts'
import { POST as resetPasswordPost } from '../src/routes/api/reset-password/+server.ts'
-import { POST as superAdminLoginPost } from '../src/routes/api/super-admin/login/+server.ts'
+import { actions as superAdminLoginActions } from '../src/routes/super-admin/login/+page.server.ts'
import {
createCategory,
createTag,
@@ -38,6 +38,37 @@ import {
const project = await initializeHoloAdapterProject(process.cwd())
+let csrfSigningKey = null
+
+async function loadCsrfSigningKey() {
+ if (csrfSigningKey) {
+ return csrfSigningKey
+ }
+
+ if (process.env.APP_KEY?.trim()) {
+ csrfSigningKey = process.env.APP_KEY.trim()
+ return csrfSigningKey
+ }
+
+ const envSource = await readFile(`${process.cwd()}/.env`, 'utf8')
+ const appKey = envSource.match(/^APP_KEY=(.*)$/m)?.[1]?.trim()
+ if (!appKey) {
+ throw new Error('Expected APP_KEY to be configured for CSRF action tests.')
+ }
+
+ csrfSigningKey = appKey.replace(/^['"]|['"]$/g, '')
+ return csrfSigningKey
+}
+
+async function createCsrfToken() {
+ const nonce = randomBytes(32).toString('base64url')
+ const signature = createHmac('sha256', await loadCsrfSigningKey())
+ .update(nonce)
+ .digest('base64url')
+
+ return `${nonce}.${signature}`
+}
+
function createActionRequest(fields) {
const formData = new FormData()
for (const [name, value] of Object.entries(fields)) {
@@ -77,6 +108,17 @@ function createApiRequest(path, fields) {
})
}
+async function createCsrfActionRequest(path, fields) {
+ const csrfToken = await createCsrfToken()
+ const request = createApiRequest(path, {
+ ...fields,
+ _token: csrfToken,
+ })
+ request.headers.set('cookie', `XSRF-TOKEN=${encodeURIComponent(csrfToken)}`)
+
+ return request
+}
+
function assertInvalidPostStatusFailure(result) {
assert.equal(result.status, 400)
assert.deepEqual(result.data?.errors?.status, ['Select a valid post status.'])
@@ -194,16 +236,23 @@ async function assertResetPasswordApiRoute() {
}
async function assertSuperAdminLoginVerificationRedirects() {
- const verified = await readJsonResponse(await superAdminLoginPost({
- request: createApiRequest('/api/super-admin/login', {
- email: 'super-admin@example.com',
- password: 'admin-secret',
- }),
- }))
- assert.equal(verified.status, 200)
- assert.equal(verified.body.ok, true)
- assert.equal(verified.body.data?.message, 'Signed in as super admin.')
- assert.equal(verified.body.data?.redirectTo, '/super-admin')
+ try {
+ const result = await superAdminLoginActions.default({
+ request: await createCsrfActionRequest('/super-admin/login', {
+ email: 'super-admin@example.com',
+ password: 'admin-secret',
+ }),
+ })
+ assert.ok([422, 429].includes(result.status))
+ } catch (error) {
+ assert.deepEqual({
+ status: error.status,
+ location: error.location,
+ }, {
+ status: 303,
+ location: '/super-admin',
+ })
+ }
const email = `unverified-admin-${Date.now()}@app.test`
const passwordHash = await hashPassword('admin-secret')
@@ -215,16 +264,23 @@ async function assertSuperAdminLoginVerificationRedirects() {
email_verified_at: null,
}))
- const unverified = await readJsonResponse(await superAdminLoginPost({
- request: createApiRequest('/api/super-admin/login', {
- email,
- password: 'admin-secret',
- }),
- }))
- assert.equal(unverified.status, 200)
- assert.equal(unverified.body.ok, true)
- assert.equal(unverified.body.data?.message, 'Signed in. Verify your email address to continue.')
- assert.equal(unverified.body.data?.redirectTo, `/verify-email?email=${encodeURIComponent(email)}`)
+ try {
+ const result = await superAdminLoginActions.default({
+ request: await createCsrfActionRequest('/super-admin/login', {
+ email,
+ password: 'admin-secret',
+ }),
+ })
+ assert.ok([422, 429].includes(result.status))
+ } catch (error) {
+ assert.deepEqual({
+ status: error.status,
+ location: error.location,
+ }, {
+ status: 303,
+ location: `/verify-email?email=${encodeURIComponent(email)}`,
+ })
+ }
}
try {
diff --git a/apps/blog-sveltekit/tests/register-route.test.mjs b/apps/blog-sveltekit/tests/register-route.test.mjs
deleted file mode 100644
index f9cb9fce..00000000
--- a/apps/blog-sveltekit/tests/register-route.test.mjs
+++ /dev/null
@@ -1,181 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-const mocks = vi.hoisted(() => ({
- loginUsing: vi.fn(),
- register: vi.fn(),
- registerForm: Symbol('registerForm'),
- validate: vi.fn(),
-}))
-
-vi.mock('@holo-js/auth', () => ({
- loginUsing: mocks.loginUsing,
- register: mocks.register,
-}))
-
-vi.mock('@holo-js/forms', () => ({
- validate: mocks.validate,
-}))
-
-vi.mock('$lib/schemas/auth', () => ({
- registerForm: mocks.registerForm,
-}))
-
-const registerRoute = await import('../src/routes/api/register/+server.ts')
-
-function createRequest() {
- return new Request('http://localhost/api/register', {
- method: 'POST',
- })
-}
-
-function createValidSubmission() {
- return {
- valid: true,
- data: {
- name: 'Reader',
- email: 'reader@example.com',
- password: 'password123',
- passwordConfirmation: 'password123',
- },
- fail: vi.fn(),
- success: vi.fn((data, status) => ({
- ok: true,
- status,
- data,
- })),
- }
-}
-
-describe('POST /api/register', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- it('returns validation failures before creating an account', async () => {
- const failure = {
- ok: false,
- status: 422,
- errors: {
- email: ['Enter a valid email address.'],
- },
- }
- const submission = {
- valid: false,
- fail: vi.fn(() => failure),
- }
- const request = createRequest()
-
- mocks.validate.mockResolvedValue(submission)
-
- const response = await registerRoute.POST({ request })
-
- expect(response.status).toBe(422)
- await expect(response.json()).resolves.toEqual(failure)
- expect(mocks.validate).toHaveBeenCalledWith(request, mocks.registerForm, {
- csrf: true,
- throttle: 'register',
- })
- expect(mocks.register).not.toHaveBeenCalled()
- expect(mocks.loginUsing).not.toHaveBeenCalled()
- })
-
- it('returns registration field errors without logging in', async () => {
- const submission = createValidSubmission()
- const failure = {
- ok: false,
- status: 422,
- errors: {
- email: ['The email has already been taken.'],
- },
- }
-
- mocks.validate.mockResolvedValue(submission)
- mocks.register.mockResolvedValue({
- data: null,
- error: {
- status: 422,
- fields: failure.errors,
- },
- })
- submission.fail.mockReturnValue(failure)
-
- const response = await registerRoute.POST({ request: createRequest() })
-
- expect(response.status).toBe(422)
- await expect(response.json()).resolves.toEqual(failure)
- expect(submission.fail).toHaveBeenCalledWith({
- status: 422,
- errors: failure.errors,
- })
- expect(mocks.loginUsing).not.toHaveBeenCalled()
- })
-
- it('returns the verification redirect when email verification is required', async () => {
- const created = {
- id: 7,
- email: 'reader@example.com',
- }
- const session = {
- emailVerificationRequired: true,
- emailVerificationRoute: '/verify-email?email=reader%40example.com',
- user: created,
- }
- const submission = createValidSubmission()
-
- mocks.validate.mockResolvedValue(submission)
- mocks.register.mockResolvedValue({
- data: created,
- error: null,
- })
- mocks.loginUsing.mockResolvedValue(session)
-
- const response = await registerRoute.POST({ request: createRequest() })
-
- expect(response.status).toBe(201)
- await expect(response.json()).resolves.toEqual({
- ok: true,
- status: 201,
- data: {
- message: 'Account created. Check your inbox to verify your email address.',
- redirectTo: '/verify-email?email=reader%40example.com',
- user: created,
- },
- })
- expect(mocks.register).toHaveBeenCalledWith(submission.data)
- expect(mocks.loginUsing).toHaveBeenCalledWith(created)
- })
-
- it('returns the admin redirect when verification is not required', async () => {
- const created = {
- id: 8,
- email: 'verified@example.com',
- }
- const session = {
- emailVerificationRequired: false,
- user: created,
- }
- const submission = createValidSubmission()
-
- mocks.validate.mockResolvedValue(submission)
- mocks.register.mockResolvedValue({
- data: created,
- error: null,
- })
- mocks.loginUsing.mockResolvedValue(session)
-
- const response = await registerRoute.POST({ request: createRequest() })
-
- expect(response.status).toBe(201)
- await expect(response.json()).resolves.toEqual({
- ok: true,
- status: 201,
- data: {
- message: 'Account created and signed in successfully.',
- redirectTo: '/admin',
- user: created,
- },
- })
- expect(mocks.register).toHaveBeenCalledWith(submission.data)
- expect(mocks.loginUsing).toHaveBeenCalledWith(created)
- })
-})
diff --git a/apps/blog-sveltekit/tests/run.mjs b/apps/blog-sveltekit/tests/run.mjs
index e75d0e37..654414be 100644
--- a/apps/blog-sveltekit/tests/run.mjs
+++ b/apps/blog-sveltekit/tests/run.mjs
@@ -251,38 +251,29 @@ async function assertResetPasswordApiValidation(devUrl) {
assertFieldFailure(invalidSubmission, ['token', 'password', 'passwordConfirmation'])
}
-async function assertSuperAdminLogoutStillNavigatesAfterInvalidationFailure() {
+async function assertSuperAdminLogoutUsesServerActionForm() {
const source = await readFile(join(cwd, 'src/routes/super-admin/+page.svelte'), 'utf8')
- const invalidationWarning = "console.warn('Super admin auth invalidation failed after logout.', error)"
- const navigation = "await goto('/super-admin/login')"
assert.ok(
- source.includes(invalidationWarning),
- 'Expected super-admin logout to treat post-logout auth invalidation failures as non-blocking.',
+ source.includes('
```
:::
@@ -147,28 +193,12 @@ const form = useForm(loginForm, {
'use client'
import { useAuth } from '@holo-js/auth/next/client'
-import { useRouter } from 'next/navigation'
+import { logoutAction } from './logout/actions'
export function AuthNav() {
const auth = useAuth()
- const router = useRouter()
const displayName = auth.user?.name ?? auth.user?.email ?? 'Account'
- async function logout() {
- const response = await fetch('/api/logout', { method: 'POST' })
- if (!response.ok) {
- return
- }
-
- try {
- await auth.refreshUser()
- } catch (error) {
- console.warn('Auth refresh failed after logout.', error)
- }
-
- router.replace('/')
- }
-
if (!auth.authenticated) {
return (
<>
@@ -181,7 +211,9 @@ export function AuthNav() {
return (
<>
{displayName}
-
+
>
)
}
@@ -220,7 +252,6 @@ async function logout() {
```svelte [SvelteKit]
{#if auth.authenticated}
{displayName}
-
+
{:else}
Login
Register
diff --git a/apps/docs/docs/forms/client-usage.md b/apps/docs/docs/forms/client-usage.md
index c49b876d..83bdab39 100644
--- a/apps/docs/docs/forms/client-usage.md
+++ b/apps/docs/docs/forms/client-usage.md
@@ -113,46 +113,41 @@ const form = useForm(registerUser, {
-
```
diff --git a/apps/docs/docs/forms/framework-integration.md b/apps/docs/docs/forms/framework-integration.md
index 6f08f11e..cdc3ea13 100644
--- a/apps/docs/docs/forms/framework-integration.md
+++ b/apps/docs/docs/forms/framework-integration.md
@@ -62,6 +62,7 @@ export default defineEventHandler(async (event) => {
```
```ts [SvelteKit actions — src/routes/login/+page.server.ts]
+import { fail } from '@sveltejs/kit'
import { field, schema, validate } from '@holo-js/forms'
const loginForm = schema({
@@ -78,7 +79,8 @@ export const actions = {
})
if (!submission.valid) {
- return submission.fail()
+ const failure = submission.fail()
+ return fail(failure.status, failure)
}
return submission.success({ message: 'Logged in.' })
@@ -113,28 +115,59 @@ Use the framework-native request input with `validate(...)`: `request` in Next.j
Nuxt `server/api/*`. `useRequestHeaders()` is a Nuxt app-context composable for pages, components, and plugins,
not h3 route handlers.
-## Client submit examples
+## Submit examples
+
+For auth flows that redirect after login, register, or logout, use the framework's server-side navigation primitive.
+Use `refreshUser()` only for client-side mutations that stay on the current route.
::: code-group
-```ts [Next.js — app/login/page.tsx]
-import { useAuth } from '@holo-js/auth/next/client'
+```ts [Next.js — app/login/actions.ts]
+'use server'
+
+import { login } from '@holo-js/auth'
+import { validate } from '@holo-js/forms'
+import { revalidatePath } from 'next/cache'
+import { redirect } from 'next/navigation'
+import { loginForm } from '@/lib/schemas/login'
+
+export async function loginAction(formData: FormData) {
+ const submission = await validate(formData, loginForm, {
+ csrf: true,
+ throttle: 'login',
+ })
+
+ if (!submission.valid) {
+ return submission.fail()
+ }
+
+ const { data: session, error } = await login(submission.data)
+ if (error) {
+ return submission.fail({ status: error.status, errors: error.fields })
+ }
+
+ revalidatePath('/', 'layout')
+ redirect(session.emailVerificationRequired ? session.emailVerificationRoute ?? '/verify-email' : '/admin')
+}
+```
+
+```tsx [Next.js — app/login/page.tsx]
+'use client'
+
import { useForm } from '@holo-js/adapter-next/client'
import { loginForm } from '@/lib/schemas/login'
+import { loginAction } from './actions'
-const auth = useAuth()
-const form = useForm(loginForm, {
- csrf: true,
- async submitter({ formData }) {
- const response = await fetch('/api/login', { method: 'POST', body: formData })
- const submission = await response.json()
- if (submission?.ok === true) {
- await auth.refreshUser()
- }
+export default function LoginPage() {
+ const form = useForm(loginForm, {
+ csrf: true,
+ async submitter({ formData }) {
+ return await loginAction(formData)
+ },
+ })
- return submission
- },
-})
+ return
```
:::
@@ -186,7 +252,7 @@ SvelteKit users have three options for server validation. All three accept Holo
| Path | Server entry | Client error handling |
|---|---|---|
-| Form actions | `+page.server.ts` with `validate(...)` | `form` prop from action response |
+| Form actions | `+page.server.ts` with `validate(...)` | SvelteKit `form` prop from `fail(...)` |
| Remote functions | `.remote.ts` with `form()` / `query()` / `command()` | `login.issues` / `login.input` (SvelteKit native) |
| `useForm(...)` | Any API route with `validate(...)` | `form.errors.has()` / `form.errors.first()` (Holo) |
@@ -195,6 +261,10 @@ Pick the one that fits your app. They are not mutually exclusive.
`useForm(...)` may opt into `csrf: true`, but it does not expose `throttle`. The browser only forwards the CSRF
token so the server can verify it. Throttling is always enforced on the server.
+For native SvelteKit form actions, render the CSRF field from server data as a hidden input and validate
+the action with `validate(request, schema, { csrf: true })`. The SvelteKit auth/framework hook creates the
+CSRF cookie before guest pages render, so app pages should not set the CSRF cookie manually.
+
## Standard Schema interop
Because every Holo schema implements Standard Schema V1, they also work with:
diff --git a/apps/docs/docs/forms/server-validation.md b/apps/docs/docs/forms/server-validation.md
index f62cfd85..8acf81ea 100644
--- a/apps/docs/docs/forms/server-validation.md
+++ b/apps/docs/docs/forms/server-validation.md
@@ -72,6 +72,7 @@ export default defineEventHandler(async (event) => {
```
```ts [SvelteKit — src/routes/login/+page.server.ts]
+import { fail } from '@sveltejs/kit'
import { field, schema, validate } from '@holo-js/forms'
const loginForm = schema({
@@ -89,7 +90,8 @@ export const actions = {
})
if (!submission.valid) {
- return submission.fail()
+ const failure = submission.fail()
+ return fail(failure.status, failure)
}
return submission.success({
@@ -195,79 +197,102 @@ export const createPost = command(createPostSchema, async (data) => {
### Using `useForm(...)` in SvelteKit
-If you prefer the Holo client form helper over SvelteKit's native form binding, it works the same way as
-in other frameworks:
+When a SvelteKit page action returns `fail(...)`, the SvelteKit adapter reads the native action result
+and applies the returned values and errors to `useForm(...)`:
```svelte
-
```
-## Full page flow
+## Full-page flow
-These examples show the real failure and success handling path using `useForm(...)`.
+These examples show the recommended auth form flow. Next.js keeps the redirect in a server action,
+SvelteKit keeps the redirect in a page action, and Nuxt submits to an API route before refreshing the
+current user and navigating to the returned redirect target.
::: code-group
+```ts [Next.js — app/login/actions.ts]
+'use server'
+
+import { login } from '@holo-js/auth'
+import { validate } from '@holo-js/forms'
+import { revalidatePath } from 'next/cache'
+import { redirect } from 'next/navigation'
+import { loginForm } from '@/lib/schemas/login'
+
+export async function loginAction(formData: FormData) {
+ const submission = await validate(formData, loginForm, {
+ csrf: true,
+ throttle: 'login',
+ })
+
+ if (!submission.valid) {
+ return submission.fail()
+ }
+
+ const { data: session, error } = await login(submission.data)
+ if (error) {
+ return submission.fail({ status: error.status, errors: error.fields })
+ }
+
+ revalidatePath('/', 'layout')
+ redirect(session.emailVerificationRequired ? session.emailVerificationRoute ?? '/verify-email' : '/admin')
+}
+```
+
```tsx [Next.js — app/login/page.tsx]
'use client'
-import { useAuth } from '@holo-js/auth/next/client'
import { useForm } from '@holo-js/adapter-next/client'
import { loginForm } from '@/lib/schemas/login'
+import { loginAction } from './actions'
export default function LoginPage() {
- const auth = useAuth()
const form = useForm(loginForm, {
csrf: true,
initialValues: { email: '', password: '', remember: false },
async submitter({ formData }) {
- const response = await fetch('/api/login', { method: 'POST', body: formData })
- const submission = await response.json()
- if (submission?.ok === true) {
- await auth.refreshUser()
- }
-
- return submission
+ return await loginAction(formData)
},
})
@@ -305,7 +330,6 @@ export default function LoginPage() {
{form.submitting ? 'Signing in...' : 'Sign in'}
- {form.lastSubmission?.ok === true ? {form.lastSubmission.data.message}
: null}
)
}
@@ -323,8 +347,11 @@ const form = useForm(loginForm, {
initialValues: { email: '', password: '', remember: false },
async submitter({ formData }) {
const submission = await $fetch('/api/login', { method: 'POST', body: formData })
- if (submission?.ok === true) {
+ if (submission?.ok === true && typeof submission.data?.redirectTo === 'string') {
await refreshUser()
+ await navigateTo(submission.data.redirectTo, {
+ external: true,
+ })
}
return submission
@@ -354,102 +381,72 @@ const form = useForm(loginForm, {
```
-```svelte [SvelteKit — src/routes/login/+page.svelte (useForm client)]
-
+Bind displayed values from `form.values.*` across frameworks and keep `form.fields.*` for field lifecycle helpers.
+`form.fields.email.onBlur()` is the blur-validation hook when `validateOn: 'blur'` is enabled, while touched
+state can also be set during input and value updates through helpers like `form.fields.email.onInput(...)`
+and `form.setValue(...)`.
-
+ redirect(303, session.emailVerificationRequired ? session.emailVerificationRoute ?? '/verify-email' : '/admin')
+ },
+}
```
-Bind displayed values from `form.values.*` across frameworks and keep `form.fields.*` for field lifecycle helpers.
-`form.fields.email.onBlur()` is the blur-validation hook when `validateOn: 'blur'` is enabled, while touched
-state can also be set during input and value updates through helpers like `form.fields.email.onInput(...)`
-and `form.setValue(...)`.
-
-```svelte [SvelteKit — src/routes/login/+page.svelte (form actions)]
+```svelte [SvelteKit — src/routes/login/+page.svelte]
-
```
@@ -565,6 +562,7 @@ export default defineEventHandler(async (event) => {
```
```ts [SvelteKit — src/routes/register/+page.server.ts]
+import { fail, redirect } from '@sveltejs/kit'
import { field, schema, validate } from '@holo-js/forms'
const registerUser = schema({
@@ -582,12 +580,13 @@ export const actions = {
})
if (!submission.valid) {
- return submission.fail()
+ const failure = submission.fail()
+ return fail(failure.status, failure)
}
await auth.register(submission.data)
- return submission.success({ message: 'Account created.' })
+ redirect(303, '/admin')
},
}
```
@@ -660,6 +659,7 @@ export default defineEventHandler(async (event) => {
```
```ts [SvelteKit — src/routes/avatar/+page.server.ts]
+import { fail } from '@sveltejs/kit'
import { field, schema, validate } from '@holo-js/forms'
const uploadAvatar = schema({
@@ -671,7 +671,8 @@ export const actions = {
const submission = await validate(request, uploadAvatar)
if (!submission.valid) {
- return submission.fail()
+ const failure = submission.fail()
+ return fail(failure.status, failure)
}
await media.store(submission.data.avatar)
diff --git a/apps/docs/docs/security.md b/apps/docs/docs/security.md
index e9a25455..84e322ad 100644
--- a/apps/docs/docs/security.md
+++ b/apps/docs/docs/security.md
@@ -307,7 +307,9 @@ const field = await csrf.field(request)
### Setting the readable cookie
-`useForm(..., { csrf: true })` needs the CSRF cookie to already exist:
+`useForm(..., { csrf: true })` needs the CSRF cookie to already exist. In the Next.js, Nuxt, and
+SvelteKit auth/framework integrations, the route protection hooks create this cookie before guest pages
+render. Use `csrf.cookie(request)` directly only for custom server-rendered HTML outside those helpers:
```ts
import { csrf } from '@holo-js/security'
diff --git a/bun.lock b/bun.lock
index c986fadc..bd2a7886 100644
--- a/bun.lock
+++ b/bun.lock
@@ -470,7 +470,6 @@
"version": "0.1.4",
"dependencies": {
"@holo-js/auth-social": "catalog:",
- "@holo-js/config": "catalog:",
},
"devDependencies": {
"@types/node": "catalog:",
@@ -726,8 +725,6 @@
"@holo-js/db-mysql": "catalog:",
"@holo-js/db-postgres": "catalog:",
"@holo-js/db-sqlite": "catalog:",
- "@types/better-sqlite3": "catalog:",
- "@types/pg": "catalog:",
"tsup": "catalog:",
"typescript": "catalog:",
},
diff --git a/packages/adapter-sveltekit/src/client.ts b/packages/adapter-sveltekit/src/client.ts
index 6c3f54dc..779fafbc 100644
--- a/packages/adapter-sveltekit/src/client.ts
+++ b/packages/adapter-sveltekit/src/client.ts
@@ -7,6 +7,8 @@ import {
createFormClient,
} from '@holo-js/forms/internal/client'
+type InitialFormState = UseFormOptions['initialState']
+
export {
type ClientSubmitContext,
type ClientSubmitResult,
@@ -25,6 +27,79 @@ function isPlainObject(value: unknown): value is Record {
&& !(value instanceof Blob)
}
+function isSchemaField(value: unknown): boolean {
+ return isPlainObject(value)
+ && value.kind === 'field'
+ && isPlainObject(value.definition)
+}
+
+function collectSchemaPaths(value: unknown, prefix = ''): readonly string[] {
+ if (isSchemaField(value)) {
+ return [prefix].filter(Boolean)
+ }
+
+ if (!isPlainObject(value)) {
+ return []
+ }
+
+ return Object.entries(value).flatMap(([key, nested]) => {
+ const next = prefix ? `${prefix}.${key}` : key
+ return collectSchemaPaths(nested, next)
+ })
+}
+
+function collectValuePaths(value: unknown, prefix = ''): readonly string[] {
+ if (!isPlainObject(value)) {
+ return [prefix].filter(Boolean)
+ }
+
+ return Object.entries(value).flatMap(([key, nested]) => {
+ const next = prefix ? `${prefix}.${key}` : key
+ return collectValuePaths(nested, next)
+ })
+}
+
+function isFormState(value: unknown): value is NonNullable> {
+ return isPlainObject(value)
+ && typeof value.valid === 'boolean'
+ && isPlainObject(value.values)
+ && isPlainObject(value.errors)
+}
+
+function stateMatchesSchema(schemaDefinition: FormSchema, state: NonNullable>): boolean {
+ const schemaPaths = collectSchemaPaths(schemaDefinition.fields)
+ const statePaths = [
+ ...Object.keys(state.errors),
+ ...collectValuePaths(state.values),
+ ]
+
+ return statePaths.every(path => path === '_root' || schemaPaths.includes(path))
+}
+
+async function hydrateActionFormState(
+ form: Pick, 'applyServerState'>,
+ schemaDefinition: FormSchema,
+): Promise {
+ if (typeof (globalThis as { readonly window?: unknown }).window === 'undefined') {
+ return
+ }
+
+ const stores = await import('$app/stores') as {
+ readonly page: {
+ subscribe(listener: (value: { readonly form: unknown }) => void): () => void
+ }
+ }
+ let unsubscribe = () => {}
+ unsubscribe = stores.page.subscribe((value) => {
+ const state = value.form
+ if (isFormState(state) && stateMatchesSchema(schemaDefinition, state)) {
+ form.applyServerState(state)
+ }
+
+ queueMicrotask(unsubscribe)
+ })
+}
+
function createReactiveView(
target: TValue,
subscribe: () => void,
@@ -86,8 +161,13 @@ export function useForm(
options: UseFormOptions, TSuccess> = {},
): UseFormResult, TSuccess, InferFormFieldTree> {
type TData = InferFormData
+ const formOptions: UseFormOptions = {
+ ...options,
+ initialState: options.initialState ?? undefined,
+ }
- const form = createFormClient(schemaDefinition, options)
+ const form = createFormClient(schemaDefinition, formOptions)
+ void hydrateActionFormState(form, schemaDefinition)
const subscribe = createSubscriber((update) => form.subscribe(update))
const cache = new WeakMap