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
12 changes: 12 additions & 0 deletions apps/blog-next/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,19 @@ STORAGE_ROUTE_PREFIX=

CACHE_PREFIX=

MAIL_MAILER=
MAIL_FROM_ADDRESS=
MAIL_FROM_NAME=
MAIL_LOG_BODIES=
MAIL_HOST=
MAIL_PORT=
MAIL_SECURE=
MAIL_USERNAME=
MAIL_PASSWORD=

AUTH_SOCIAL_ENCRYPTION_KEY=
AUTH_EMAIL_VERIFICATION_ROUTE=/verify-email
AUTH_PASSWORD_RESET_ROUTE=/reset-password
SESSION_DRIVER=
SESSION_CONNECTION=
SESSION_COOKIE=
Expand Down
12 changes: 10 additions & 2 deletions apps/blog-next/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import Link from 'next/link'
import { user } from '@holo-js/auth'

import { getAdminDashboardData } from '@/server/lib/blog'

export const dynamic = 'force-dynamic'

export default async function AdminDashboardPage() {
const dashboard = await getAdminDashboardData()
const [dashboard, currentUser] = await Promise.all([
getAdminDashboardData(),
user(),
])
const displayName = currentUser?.name ?? currentUser?.email ?? 'Editor'

return (
<section style={{ display: 'grid', gap: '1rem' }}>
<h1 style={{ margin: 0 }}>Admin</h1>
<div style={{ display: 'grid', gap: '0.35rem' }}>
<h1 style={{ margin: 0 }}>Admin</h1>
<p style={{ margin: 0, color: '#94a3b8' }}>Signed in as {displayName}</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(12rem, 1fr))', gap: '1rem' }}>
<article style={{ padding: '1rem', borderRadius: '1rem', background: '#111827' }}><strong>{dashboard.postCount}</strong><div>Posts</div></article>
<article style={{ padding: '1rem', borderRadius: '1rem', background: '#111827' }}><strong>{dashboard.publishedCount}</strong><div>Published</div></article>
Expand Down
9 changes: 9 additions & 0 deletions apps/blog-next/app/api/auth/user/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { check, user } from '@holo-js/auth'

export async function GET() {
return Response.json({
authenticated: await check(),
guard: 'web',
user: await user(),
})
}
31 changes: 31 additions & 0 deletions apps/blog-next/app/api/forgot-password/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { requestPasswordReset } from '@holo-js/auth'
import { sanitizeFlashedInput, validate } from '@holo-js/forms'

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

export async function POST(request: Request) {
const submission = await validate(request, forgotPasswordForm)

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

const { error } = await requestPasswordReset(submission.data)
if (error) {
return Response.json({
ok: false as const,
status: error.status,
valid: false as const,
values: sanitizeFlashedInput(submission.values),
errors: error.fields,
}, {
status: error.status,
})
}

return Response.json(submission.success({
message: 'If an account exists for that email, a reset link has been sent.',
}))
}
46 changes: 46 additions & 0 deletions apps/blog-next/app/api/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { login } from '@holo-js/auth'
import { sanitizeFlashedInput, 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 login(submission.data)
if (error) {
return Response.json({
ok: false as const,
status: error.status,
valid: false as const,
values: sanitizeFlashedInput(submission.values),
errors: error.fields,
}, {
status: error.status,
})
}

const headers = new Headers()
for (const cookie of session.cookies) {
headers.append('set-cookie', cookie)
}

return Response.json(submission.success({
message: session.emailVerificationRequired
? 'Signed in. Verify your email address to continue.'
: 'Signed in successfully.',
redirectTo: session.emailVerificationRequired
? session.emailVerificationRoute ?? '/verify-email'
: '/admin',
user: session.user,
}), {
headers,
})
}
18 changes: 18 additions & 0 deletions apps/blog-next/app/api/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { logout, user } from '@holo-js/auth'

export async function POST() {
const signedOut = await logout()
const headers = new Headers()
for (const cookie of signedOut.cookies) {
headers.append('set-cookie', cookie)
}

return Response.json({
ok: true,
authenticated: false,
message: 'Signed out successfully.',
user: await user(),
}, {
headers,
})
}
48 changes: 48 additions & 0 deletions apps/blog-next/app/api/register/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { loginUsing, register } from '@holo-js/auth'
import { sanitizeFlashedInput, validate } from '@holo-js/forms'

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

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

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

const { data: created, error } = await register(submission.data)
if (error) {
return Response.json({
ok: false as const,
status: error.status,
valid: false as const,
values: sanitizeFlashedInput(submission.values),
errors: error.fields,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}, {
status: error.status,
})
}

const session = await loginUsing(created)
const headers = new Headers()
for (const cookie of session.cookies) {
headers.append('set-cookie', cookie)
}

return Response.json(submission.success({
message: session.emailVerificationRequired
? 'Account created. Check your inbox to verify your email address.'
: 'Account created and signed in successfully.',
redirectTo: session.emailVerificationRequired
? session.emailVerificationRoute ?? '/verify-email'
: '/admin',
user: session.user,
}, 201), {
status: 201,
headers,
})
}
32 changes: 32 additions & 0 deletions apps/blog-next/app/api/reset-password/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { resetPassword } from '@holo-js/auth'
import { sanitizeFlashedInput, validate } from '@holo-js/forms'

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

export async function POST(request: Request) {
const submission = await validate(request, resetPasswordForm)

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

const { error } = await resetPassword(submission.data)
if (error) {
return Response.json({
ok: false as const,
status: error.status,
valid: false as const,
values: sanitizeFlashedInput(submission.values),
errors: error.fields,
}, {
status: error.status,
})
}

return Response.json(submission.success({
message: 'Password reset successfully. You can sign in with your new password.',
redirectTo: '/login',
}))
}
42 changes: 42 additions & 0 deletions apps/blog-next/app/api/verify-email/resend/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { verification } from '@holo-js/auth'

interface ResendVerificationRequestBody {
readonly email?: string
}

async function readRequestBody(request: Request): Promise<ResendVerificationRequestBody> {
const contentType = request.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
return {}
}

const payload = await request.json().catch(() => null)
if (!payload || typeof payload !== 'object') {
return {}
}

const email = typeof payload.email === 'string' ? payload.email.trim() : undefined
return email ? { email } : {}
}

export async function POST(request: Request) {
const input = await readRequestBody(request)
const { error } = await verification.resend(input)
if (error) {
return Response.json({
ok: false as const,
status: error.status,
errors: error.fields,
}, {
status: error.status,
})
}

return Response.json({
ok: true as const,
status: 200,
data: {
message: 'A fresh verification email has been sent.',
},
})
}
32 changes: 32 additions & 0 deletions apps/blog-next/app/api/verify-email/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { verification } from '@holo-js/auth'
import { sanitizeFlashedInput, validate } from '@holo-js/forms'

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

export async function POST(request: Request) {
const submission = await validate(request, verifyEmailForm)

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

const { error } = await verification.consume(submission.data.token)
if (error) {
return Response.json({
ok: false as const,
status: error.status,
valid: false as const,
values: sanitizeFlashedInput(submission.values),
errors: error.fields,
}, {
status: error.status,
})
}

return Response.json(submission.success({
message: 'Email address verified. You can sign in now.',
redirectTo: '/login',
}))
}
59 changes: 59 additions & 0 deletions apps/blog-next/app/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client'

import Link from 'next/link'

import { useForm } from '@holo-js/adapter-next/client'

import { forgotPasswordForm } 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 ForgotPasswordPage() {
const form = useForm(forgotPasswordForm, {
validateOn: 'blur',
initialValues: { email: '' },
async submitter({ formData }) {
const response = await fetch('/api/forgot-password', { method: 'POST', body: formData })
return await response.json()
},
})

return (
<section style={panelStyle}>
<div>
<h1 style={{ margin: '0 0 0.5rem 0' }}>Forgot password</h1>
<p style={{ margin: 0, color: '#94a3b8' }}>Request a password reset link for your local account.</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.fields.email.value}
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>

<button type="submit" disabled={form.submitting}>
{form.submitting ? 'Sending link...' : 'Send reset link'}
</button>
</form>

{form.lastSubmission?.ok === true ? <p style={{ margin: 0, color: '#86efac' }}>A password reset link has been sent if the account exists.</p> : null}

<Link href="/login" style={{ color: '#7dd3fc' }}>Back to sign in</Link>
</section>
)
}
2 changes: 2 additions & 0 deletions apps/blog-next/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export default 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>
<Link href="/login" style={{ color: '#cbd5e1', textDecoration: 'none' }}>Login</Link>
<Link href="/register" style={{ color: '#cbd5e1', textDecoration: 'none' }}>Register</Link>
</nav>
</header>
<main style={{ maxWidth: '72rem', margin: '0 auto', padding: '2rem 1.5rem 4rem' }}>{children}</main>
Expand Down
Loading