Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions apps/blog-next/app/api-token-posts/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TokenPostsClient } from './token-posts-client'

export const dynamic = 'force-dynamic'

export default function ApiTokenPostsPage() {
return <TokenPostsClient />
}
129 changes: 129 additions & 0 deletions apps/blog-next/app/api-token-posts/token-posts-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
'use client'

import { type FormEvent, useState } from 'react'

type JsonResult = {
readonly status: number
readonly payload: unknown
}

function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}

function getStringField(value: unknown, field: string): string {
if (!isRecord(value)) {
return ''
}

const fieldValue = value[field]
return typeof fieldValue === 'string' ? fieldValue : ''
}

async function readJson(response: Response): Promise<unknown> {
try {
return await response.json()
} catch {
return {
ok: false,
message: 'Response was not valid JSON.',
}
}
}

export function TokenPostsClient() {
const [tokenResult, setTokenResult] = useState<JsonResult | null>(null)
const [postsResult, setPostsResult] = useState<JsonResult | null>(null)
const [creatingToken, setCreatingToken] = useState(false)
const [fetchingPosts, setFetchingPosts] = useState(false)
const generatedToken = getStringField(tokenResult?.payload, 'token')

async function createToken(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setCreatingToken(true)
setPostsResult(null)

try {
const response = await fetch('/api/v1/tokens', {
method: 'POST',
body: new FormData(event.currentTarget),
})

setTokenResult({
status: response.status,
payload: await readJson(response),
})
} finally {
setCreatingToken(false)
}
}

async function fetchPosts(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setFetchingPosts(true)

const formData = new FormData(event.currentTarget)
const token = String(formData.get('token') ?? '').trim()

try {
const response = await fetch('/api/v1/posts', {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
})

setPostsResult({
status: response.status,
payload: await readJson(response),
})
} finally {
setFetchingPosts(false)
}
}

return (
<section style={{ display: 'grid', gap: '1rem', maxWidth: '44rem' }}>
<div>
<h1 style={{ margin: '0 0 0.5rem 0' }}>API token posts</h1>
<p style={{ margin: 0, color: '#94a3b8' }}>Generate a bearer token from credentials, then use it to fetch protected posts.</p>
</div>

<form onSubmit={createToken} style={{ display: 'grid', gap: '0.9rem', padding: '1.25rem', borderRadius: '1rem', background: '#111827', border: '1px solid rgba(148, 163, 184, 0.16)' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>Create token</h2>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span>Email</span>
<input name="email" type="email" placeholder="editor@example.com" required />
</label>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span>Password</span>
<input name="password" type="password" placeholder="secret-secret" required />
</label>
<button type="submit" disabled={creatingToken}>{creatingToken ? 'Creating...' : 'Create token'}</button>
</form>

{tokenResult ? (
<section style={{ display: 'grid', gap: '0.65rem', padding: '1.25rem', borderRadius: '1rem', background: '#111827', border: '1px solid rgba(148, 163, 184, 0.16)' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>Token response ({tokenResult.status})</h2>
{generatedToken ? (
<textarea readOnly value={generatedToken} rows={3} style={{ width: '100%', boxSizing: 'border-box' }} />
) : null}
<pre style={{ margin: 0, overflowX: 'auto' }}>{JSON.stringify(tokenResult.payload, null, 2)}</pre>
</section>
) : null}

<form onSubmit={fetchPosts} style={{ display: 'grid', gap: '0.9rem', padding: '1.25rem', borderRadius: '1rem', background: '#111827', border: '1px solid rgba(148, 163, 184, 0.16)' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>Fetch posts</h2>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span>Bearer token</span>
<textarea name="token" rows={3} required />
</label>
<button type="submit" disabled={fetchingPosts}>{fetchingPosts ? 'Fetching...' : 'Fetch posts'}</button>
</form>

{postsResult ? (
<section style={{ display: 'grid', gap: '0.65rem', padding: '1.25rem', borderRadius: '1rem', background: '#111827', border: '1px solid rgba(148, 163, 184, 0.16)' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>Posts response ({postsResult.status})</h2>
<pre style={{ margin: 0, overflowX: 'auto' }}>{JSON.stringify(postsResult.payload, null, 2)}</pre>
</section>
) : null}
</section>
)
}
34 changes: 34 additions & 0 deletions apps/blog-next/app/api/v1/posts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import auth from '@holo-js/auth'

import Post from '@/server/models/Post'

export async function GET() {
const currentUser = await auth.guard('api').user()

if (!currentUser) {
return Response.json({
ok: false,
message: 'Unauthenticated.',
}, { status: 401 })
}

const userId = currentUser.id

if (!currentUser.can('posts.read')) {
return Response.json({
ok: false,
message: 'Forbidden.',
}, { status: 403 })
}

const posts = await Post
.with('category', 'tags')
.where('user_id', userId)
.orderBy('published_at', 'desc')
.get()

return Response.json({
ok: true,
posts,
})
}
40 changes: 40 additions & 0 deletions apps/blog-next/app/api/v1/tokens/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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) {
const failure = submission.fail()
return Response.json(failure, {
status: failure.status,
})
}

const { data: token, error } = await auth.guard('api').login({
...submission.data,
abilities: ['posts.read'],
})

if (error) {
return Response.json({
ok: false,
message: 'Invalid credentials.',
}, { status: 401 })
}

return Response.json({
ok: true,
token: token.plainTextToken,
tokenId: token.id,
abilities: token.abilities,
}, {
headers: {
'Cache-Control': 'no-store',
},
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
1 change: 1 addition & 0 deletions apps/blog-next/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
<nav style={{ maxWidth: '72rem', margin: '0 auto', display: 'flex', gap: '1rem', alignItems: 'center', padding: '1rem 1.5rem', flexWrap: 'wrap' }}>
<Link href="/" style={{ color: '#fff', textDecoration: 'none', fontWeight: 700 }}>blog-next</Link>
<Link href="/posts" style={{ color: '#cbd5e1', textDecoration: 'none' }}>Posts</Link>
<Link href="/api-token-posts" style={{ color: '#cbd5e1', textDecoration: 'none' }}>API Token</Link>
<Link href="/admin" style={{ color: '#cbd5e1', textDecoration: 'none' }}>Admin</Link>
<Link href="/super-admin" style={{ color: '#cbd5e1', textDecoration: 'none' }}>Super Admin</Link>
<AuthProvider initialProvider={currentAuth.provider} initialUser={currentAuth.user}>
Expand Down
4 changes: 4 additions & 0 deletions apps/blog-next/config/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export default defineAuthConfig({
driver: 'session',
provider: 'admins',
},
api: {
driver: 'token',
provider: 'users',
},
},
providers: {
users: {
Expand Down
2 changes: 1 addition & 1 deletion apps/blog-next/server/db/seeders/BlogSeeder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default defineSeeder({
name: 'BlogSeeder',
async run() {
const timestamp = new Date('2026-04-26T09:00:00.000Z')
const userPassword = await hashPassword('secret')
const userPassword = await hashPassword('secret-secret')
const adminPassword = await hashPassword('admin-secret')

const author = await User.unguarded(() =>
Expand Down
5 changes: 4 additions & 1 deletion apps/blog-next/server/lib/blog.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { hashPassword } from '@holo-js/auth'

import Post from '../models/Post'
import User from '../models/User'
import Category from '../models/Category'
Expand All @@ -13,11 +15,12 @@ async function ensureAuthorId(): Promise<number> {
return existing.id
}

const password = await hashPassword('secret-secret')
const user = await User.unguarded(() =>
User.create({
name: 'Holo Editor',
email: 'editor@example.com',
password: 'secret',
password,
avatar: null,
email_verified_at: now(),
}),
Expand Down
5 changes: 5 additions & 0 deletions apps/blog-next/tests/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { DEFAULT_SESSION_COOKIE_NAME } from '@holo-js/config'
import { assertExampleAppAuthFlow } from '../../../tests/example-app-auth-flow.mjs'
import { assertExampleAppTokenAuthFlow } from '../../../tests/example-app-token-auth-flow.mjs'

const cwd = process.cwd()
const configPath = join(cwd, 'config/app.ts')
Expand Down Expand Up @@ -252,6 +253,10 @@ try {
appName: 'blog-next',
sessionCookieName: DEFAULT_SESSION_COOKIE_NAME,
})
await assertExampleAppTokenAuthFlow({
baseUrl: `http://localhost:${port}`,
expectedTitle: 'Shipping a Real Holo Blog on Next',
})

await writeFile(configPath, originalConfig.replace("name: env('APP_NAME', 'blog-next')", "name: env('APP_NAME', 'blog-next-updated')"))
const updated = await waitForJson(healthUrl, payload => payload.app === 'blog-next-updated')
Expand Down
1 change: 1 addition & 0 deletions apps/blog-nuxt/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ async function logout() {
<nav class="nav">
<NuxtLink to="/" class="brand">blog-nuxt</NuxtLink>
<NuxtLink to="/posts">Posts</NuxtLink>
<NuxtLink to="/api-token-posts">API Token</NuxtLink>
<NuxtLink to="/admin">Admin</NuxtLink>
<NuxtLink to="/super-admin">Super Admin</NuxtLink>
<template v-if="authenticated">
Expand Down
Loading