Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/blog-next/app/api/auth/clerk/logout/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { provider } from '@holo-js/auth'
import { logoutWithClerk } from '@holo-js/auth-clerk'

export async function POST(request: Request) {
if (await provider() !== 'clerk') {
return Response.redirect(new URL('/', request.url), 303)
}

const result = await logoutWithClerk(request)
if (!result.ok) {
return Response.json(result, { status: 422 })
Expand Down
14 changes: 9 additions & 5 deletions apps/blog-next/app/api/auth/user/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { check, user } from '@holo-js/auth'
import auth, { check, provider, user } from '@holo-js/auth'

export async function GET(request: Request) {
const guard = new URL(request.url).searchParams.get('guard') ?? undefined
const guardAuth = guard ? auth.guard(guard) : undefined

export async function GET() {
return Response.json({
authenticated: await check(),
guard: 'web',
user: await user(),
authenticated: guardAuth ? await guardAuth.check() : await check(),
guard: guard ?? 'web',
provider: guardAuth ? await guardAuth.provider() : await provider(),
user: guardAuth ? await guardAuth.user() : await user(),
})
}
5 changes: 5 additions & 0 deletions apps/blog-next/app/api/auth/workos/logout/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { provider } from '@holo-js/auth'
import { logoutWithWorkos } from '@holo-js/auth-workos'

export async function POST(request: Request) {
if (await provider() !== 'workos') {
return Response.redirect(new URL('/', request.url), 303)
}

const result = await logoutWithWorkos(request)
if (!result.ok) {
return Response.json(result, { status: 422 })
Expand Down
32 changes: 32 additions & 0 deletions apps/blog-next/app/api/super-admin/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import auth from '@holo-js/auth'
import { validate } from '@holo-js/forms'

import { loginForm } from '@/lib/schemas/auth'

export async function POST(request: Request) {
const submission = await validate(request, loginForm, {
throttle: 'login',
})

if (!submission.valid) {
return Response.json(submission.fail(), {
status: submission.fail().status,
})
}

const { data: session, error } = await auth.guard('admin').login(submission.data)
if (error) {
const failure = submission.fail({
status: error.status,
errors: error.fields,
})

return Response.json(failure, { status: failure.status })
}

return Response.json(submission.success({
message: 'Signed in as super admin.',
redirectTo: '/super-admin',
user: session.user,
}))
}
13 changes: 13 additions & 0 deletions apps/blog-next/app/api/super-admin/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import auth from '@holo-js/auth'

export async function POST() {
const admin = auth.guard('admin')
await admin.logout()

return Response.json({
ok: true,
authenticated: false,
message: 'Signed out of super admin.',
user: await admin.user(),
})
}
16 changes: 10 additions & 6 deletions apps/blog-next/app/auth-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,16 @@ export function AuthNav() {
<>
<span style={{ color: '#e5eef8' }}>{displayName}</span>
<button type="button" disabled={isLoggingOut} onClick={logout} style={logoutButtonStyle}>Logout</button>
<form action="/api/auth/workos/logout" method="post" style={logoutFormStyle}>
<button type="submit" style={logoutButtonStyle}>Logout from WorkOS</button>
</form>
<form action="/api/auth/clerk/logout" method="post" style={logoutFormStyle}>
<button type="submit" style={logoutButtonStyle}>Logout from Clerk</button>
</form>
{auth.provider === 'workos' && (
<form action="/api/auth/workos/logout" method="post" style={logoutFormStyle}>
<button type="submit" style={logoutButtonStyle}>Logout from WorkOS</button>
</form>
)}
{auth.provider === 'clerk' && (
<form action="/api/auth/clerk/logout" method="post" style={logoutFormStyle}>
<button type="submit" style={logoutButtonStyle}>Logout from Clerk</button>
</form>
)}
</>
)
}
3 changes: 2 additions & 1 deletion apps/blog-next/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export default async function RootLayout({ children }: { children: ReactNode })
<Link href="/" style={{ color: '#fff', textDecoration: 'none', fontWeight: 700 }}>blog-next</Link>
<Link href="/posts" style={{ color: '#cbd5e1', textDecoration: 'none' }}>Posts</Link>
<Link href="/admin" style={{ color: '#cbd5e1', textDecoration: 'none' }}>Admin</Link>
<AuthProvider initialUser={currentAuth.user}>
<Link href="/super-admin" style={{ color: '#cbd5e1', textDecoration: 'none' }}>Super Admin</Link>
<AuthProvider initialProvider={currentAuth.provider} initialUser={currentAuth.user}>
<AuthNav />
</AuthProvider>
</nav>
Expand Down
93 changes: 93 additions & 0 deletions apps/blog-next/app/super-admin/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
'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'

const panelStyle = {
display: 'grid',
gap: '1rem',
maxWidth: '32rem',
padding: '1.5rem',
borderRadius: '1rem',
background: '#111827',
border: '1px solid rgba(148, 163, 184, 0.16)',
} 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('Admin auth refresh failed after login.', error)
}
router.replace(submission.data.redirectTo)
}
return submission
},
})

return (
<section style={panelStyle}>
<div>
<h1 style={{ margin: '0 0 0.5rem 0' }}>Super Admin Sign In</h1>
<p style={{ margin: 0, color: '#94a3b8' }}>Use an admin account to access the super admin area.</p>
</div>

<form onSubmit={(event) => { event.preventDefault(); form.submit() }} style={{ display: 'grid', gap: '0.9rem' }}>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span>Email</span>
<input
name="email"
type="email"
value={form.values.email}
onInput={(event) => form.fields.email.onInput(event.currentTarget.value)}
onBlur={() => form.fields.email.onBlur()}
/>
{form.errors.has('email') ? <span style={{ color: '#fca5a5' }}>{form.errors.first('email')}</span> : null}
</label>

<label style={{ display: 'grid', gap: '0.35rem' }}>
<span>Password</span>
<input
name="password"
type="password"
value={form.values.password}
onInput={(event) => form.fields.password.onInput(event.currentTarget.value)}
onBlur={() => form.fields.password.onBlur()}
/>
{form.errors.has('password') ? <span style={{ color: '#fca5a5' }}>{form.errors.first('password')}</span> : null}
</label>

<label style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input
name="remember"
type="checkbox"
checked={form.values.remember}
onChange={(event) => form.fields.remember.onInput(event.currentTarget.checked)}
/>
Remember me
</label>

<button type="submit" disabled={form.submitting}>
{form.submitting ? 'Signing in...' : 'Sign in as super admin'}
</button>
</form>

{form.lastSubmission?.ok === true ? (
<div style={{ color: '#86efac' }}>
<p style={{ margin: 0 }}>Signed in as super admin.</p>
</div>
) : null}
</section>
)
}
39 changes: 39 additions & 0 deletions apps/blog-next/app/super-admin/logout-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@holo-js/auth/next/client'

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
}

await auth.refreshUser()
router.replace('/super-admin/login')
} catch (error) {
console.warn('Super admin logout failed.', error)
} finally {
setIsLoggingOut(false)
}
}

return (
<button type="button" onClick={logout} disabled={isLoggingOut}>
{isLoggingOut ? 'Signing out...' : 'Sign out of super admin'}
</button>
)
}
24 changes: 24 additions & 0 deletions apps/blog-next/app/super-admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { redirect } from 'next/navigation'
import { auth } from '@holo-js/auth/next/server'
import { SuperAdminLogoutButton } from './logout-button'

export default async function SuperAdminPage() {
const currentAuth = await auth({ guard: 'admin' })

if (!currentAuth.authenticated) {
redirect('/super-admin/login')
}

const displayName = currentAuth.user?.name ?? currentAuth.user?.email ?? 'Super Admin'

return (
<section style={{ display: 'grid', gap: '0.75rem', maxWidth: '42rem' }}>
<p style={{ margin: 0, color: '#7dd3fc', fontSize: '0.875rem', textTransform: 'uppercase' }}>Admin guard</p>
<h1 style={{ margin: 0 }}>Super Admin</h1>
<p style={{ margin: 0, color: '#cbd5e1' }}>Signed in as {displayName} through the admin guard.</p>
<div>
<SuperAdminLogoutButton />
</div>
</section>
)
}
16 changes: 8 additions & 8 deletions apps/blog-next/config/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,20 @@ export default defineAuthConfig({
driver: 'session',
provider: 'users',
},
// admin: {
// driver: 'session',
// provider: 'admins',
// },
admin: {
driver: 'session',
provider: 'admins',
},
},
providers: {
users: {
model: 'User',
identifiers: ['email'],
},
// admins: {
// model: 'Admin',
// identifiers: ['email'],
// },
admins: {
model: 'Admin',
identifiers: ['email'],
},
},
passwords: {
users: {
Expand Down
12 changes: 11 additions & 1 deletion apps/blog-next/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,18 @@ export const proxy = protectRoutes(
routes: ['/admin/*'],
redirectTo: '/login',
}),
guestOnly({
routes: ['/super-admin/login'],
guard: 'admin',
redirectTo: '/super-admin',
}),
authOnly({
routes: ['/super-admin'],
guard: 'admin',
redirectTo: '/super-admin/login',
}),
)

export const config = {
matcher: ['/login', '/register', '/forgot-password', '/reset-password', '/admin/:path*'],
matcher: ['/login', '/register', '/forgot-password', '/reset-password', '/admin/:path*', '/super-admin', '/super-admin/login'],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { defineMigration, type MigrationContext } from '@holo-js/db'

export default defineMigration({
async up({ schema }: MigrationContext) {
await schema.createTable('admins', (table) => {
table.id()
table.string('name')
table.string('email').unique()
table.string('password').nullable()
table.string('avatar').nullable()
table.timestamp('email_verified_at').nullable()
table.timestamps()
})
},
async down({ schema }: MigrationContext) {
await schema.dropTable('admins')
},
})
16 changes: 15 additions & 1 deletion apps/blog-next/server/db/seeders/BlogSeeder.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import { hashPassword } from '@holo-js/auth'
import { defineSeeder } from '@holo-js/db'

import Post from '../../models/Post'
import User from '../../models/User'
import Category from '../../models/Category'
import Tag from '../../models/Tag'
import Admin from '../../models/Admin'

export default defineSeeder({
name: 'BlogSeeder',
async run() {
const timestamp = new Date('2026-04-26T09:00:00.000Z')
const userPassword = await hashPassword('secret')
const adminPassword = await hashPassword('admin-secret')
Comment on lines +14 to +15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid hardcoded seeded passwords for privileged accounts.

Using fixed credentials ('secret' / 'admin-secret') creates a predictable login path if this seed data reaches non-local environments. Please source these from environment variables (or fail when missing outside local/dev).

Suggested hardening
-    const userPassword = await hashPassword('secret')
-    const adminPassword = await hashPassword('admin-secret')
+    const seededUserPassword = process.env.SEED_USER_PASSWORD
+    const seededAdminPassword = process.env.SEED_ADMIN_PASSWORD
+    if (!seededUserPassword || !seededAdminPassword) {
+      throw new Error('Missing SEED_USER_PASSWORD or SEED_ADMIN_PASSWORD')
+    }
+    const userPassword = await hashPassword(seededUserPassword)
+    const adminPassword = await hashPassword(seededAdminPassword)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const userPassword = await hashPassword('secret')
const adminPassword = await hashPassword('admin-secret')
const seededUserPassword = process.env.SEED_USER_PASSWORD
const seededAdminPassword = process.env.SEED_ADMIN_PASSWORD
if (!seededUserPassword || !seededAdminPassword) {
throw new Error('Missing SEED_USER_PASSWORD or SEED_ADMIN_PASSWORD')
}
const userPassword = await hashPassword(seededUserPassword)
const adminPassword = await hashPassword(seededAdminPassword)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/blog-next/server/db/seeders/BlogSeeder.ts` around lines 14 - 15, The
seed currently uses hardcoded passwords ('secret'/'admin-secret') via
hashPassword to set userPassword and adminPassword in BlogSeeder; change it to
read passwords from environment variables (e.g., process.env.SEED_USER_PASSWORD
and process.env.SEED_ADMIN_PASSWORD) and only allow defaults in local/dev
(NODE_ENV==='development' or similar), otherwise throw/exit if the env vars are
missing so privileged accounts cannot be seeded with predictable credentials;
update the userPassword/adminPassword assignments to use these env values before
calling hashPassword and add a clear error path when required vars are absent.


const author = await User.unguarded(() =>
User.create({
name: 'Holo Editor',
email: 'editor@example.com',
password: 'secret',
password: userPassword,
avatar: null,
email_verified_at: timestamp,
}),
)

await Admin.unguarded(() =>
Admin.create({
name: 'Super Admin',
email: 'super-admin@example.com',
password: adminPassword,
avatar: null,
email_verified_at: timestamp,
}),
Expand Down
Loading