Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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>
)
}
26 changes: 26 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,26 @@
import auth from '@holo-js/auth'

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

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

if (typeof userId === 'undefined') {
return Response.json({
ok: false,
message: 'Unauthenticated.',
}, { status: 401 })
}

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

return Response.json({
ok: true,
posts,
})
}
33 changes: 33 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,33 @@
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)

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,
})
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
6 changes: 5 additions & 1 deletion 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 All @@ -39,7 +43,7 @@ export default defineAuthConfig({
route: env('AUTH_EMAIL_VERIFICATION_ROUTE', '/verify-email'),
},
personalAccessTokens: {
defaultAbilities: [],
defaultAbilities: ['posts.read'],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
},
social: {
google: {
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