Style wiki auth screens with guild design system

Add guild-styled HTML templates for OIDC logout confirmation, post-logout
success, and error pages. Update wiki login heading to brand convention
(candlelight + warm-text). Restyle magic link email from blue to guild
colour tokens.
This commit is contained in:
Jennie Robinson Faber 2026-03-04 17:26:48 +00:00
parent 79d3ba0f78
commit bf57f4b33d
3 changed files with 175 additions and 18 deletions

View file

@ -31,12 +31,12 @@ async function sendMagicLink() {
</script> </script>
<template> <template>
<div class="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <div class="min-h-screen flex items-center justify-center bg-guild-900 px-4">
<div class="w-full max-w-sm"> <div class="w-full max-w-sm">
<div class="bg-white rounded-lg shadow-md p-8"> <div class="bg-guild-800 rounded-lg shadow-md p-8">
<div class="text-center mb-6"> <div class="text-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">Ghost Guild Wiki</h1> <h1 class="text-display-sm font-bold text-candlelight-400 warm-text">Ghost Guild Wiki</h1>
<p class="mt-2 text-sm text-gray-600"> <p class="mt-2 text-sm text-guild-400">
Sign in with your Ghost Guild account Sign in with your Ghost Guild account
</p> </p>
</div> </div>
@ -44,7 +44,7 @@ async function sendMagicLink() {
<template v-if="!sent"> <template v-if="!sent">
<form @submit.prevent="sendMagicLink" class="space-y-4"> <form @submit.prevent="sendMagicLink" class="space-y-4">
<div> <div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1"> <label for="email" class="block text-sm font-medium text-guild-300 mb-1">
Email address Email address
</label> </label>
<input <input
@ -53,23 +53,23 @@ async function sendMagicLink() {
type="email" type="email"
required required
placeholder="you@example.com" placeholder="you@example.com"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm" class="w-full px-3 py-2 border border-guild-600 rounded-md shadow-sm bg-guild-900 text-guild-100 focus:ring-2 focus:ring-candlelight-500 focus:border-candlelight-500 text-sm"
:disabled="loading" :disabled="loading"
/> />
</div> </div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p> <p v-if="error" class="text-sm text-ember-400">{{ error }}</p>
<button <button
type="submit" type="submit"
:disabled="loading || !email" :disabled="loading || !email"
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed" class="w-full py-2 px-4 bg-candlelight-600 text-guild-100 text-sm font-medium rounded-md hover:bg-candlelight-500 focus:outline-none focus:ring-2 focus:ring-candlelight-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{{ loading ? "Sending..." : "Send magic link" }} {{ loading ? "Sending..." : "Send magic link" }}
</button> </button>
</form> </form>
<p class="mt-4 text-xs text-center text-gray-500"> <p class="mt-4 text-xs text-center text-guild-500">
We'll send a sign-in link to your email. We'll send a sign-in link to your email.
</p> </p>
</template> </template>
@ -77,14 +77,14 @@ async function sendMagicLink() {
<template v-else> <template v-else>
<div class="text-center space-y-3"> <div class="text-center space-y-3">
<div class="text-4xl"></div> <div class="text-4xl"></div>
<h2 class="text-lg font-semibold text-gray-900">Check your email</h2> <h2 class="text-lg font-semibold text-guild-100">Check your email</h2>
<p class="text-sm text-gray-600"> <p class="text-sm text-guild-400">
We sent a sign-in link to <strong>{{ email }}</strong>. We sent a sign-in link to <strong>{{ email }}</strong>.
Click the link in the email to continue. Click the link in the email to continue.
</p> </p>
<button <button
@click="sent = false; email = '';" @click="sent = false; email = '';"
class="mt-4 text-sm text-blue-600 hover:text-blue-800" class="mt-4 text-sm text-candlelight-600 hover:text-candlelight-500"
> >
Use a different email Use a different email
</button> </button>

View file

@ -54,16 +54,16 @@ export default defineEventHandler(async (event) => {
to: email, to: email,
subject: "Sign in to Ghost Guild Wiki", subject: "Sign in to Ghost Guild Wiki",
html: ` html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Georgia, 'Times New Roman', serif; max-width: 600px; margin: 0 auto; background-color: #2a241c; border-radius: 8px; padding: 32px;">
<h2 style="color: #2563eb;">Sign in to the Ghost Guild Wiki</h2> <h2 style="color: #d09e4e; margin-top: 0;">Sign in to the Ghost Guild Wiki</h2>
<p>Click the button below to sign in:</p> <p style="color: #bfb3a2;">Click the button below to sign in:</p>
<div style="text-align: center; margin: 30px 0;"> <div style="text-align: center; margin: 30px 0;">
<a href="${baseUrl}/oidc/interaction/verify?token=${token}" <a href="${baseUrl}/oidc/interaction/verify?token=${token}"
style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;"> style="background-color: #9a6f2c; color: #f0ebe4; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: 600;">
Sign In Sign In
</a> </a>
</div> </div>
<p style="color: #666; font-size: 14px;"> <p style="color: #6b5f4d; font-size: 14px;">
This link expires in 15 minutes. If you didn't request this, you can safely ignore this email. This link expires in 15 minutes. If you didn't request this, you can safely ignore this email.
</p> </p>
</div> </div>

View file

@ -11,6 +11,126 @@ import { MongoAdapter } from "./oidc-mongodb-adapter.js";
import Member from "../models/member.js"; import Member from "../models/member.js";
import { connectDB } from "./mongoose.js"; import { connectDB } from "./mongoose.js";
/**
* Renders a standalone HTML page in the guild dark style.
* Used for OIDC logout/error screens that are served outside Nuxt.
*/
function guildPageShell(title: string, bodyContent: string, extraStyles = "") {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title} Ghost Guild</title>
<style>
@font-face {
font-family: 'Quietism';
src: url('/fonts/Quietism-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Quietism';
src: url('/fonts/Quietism-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: #1a1510;
background-image:
radial-gradient(ellipse at 20% 50%, rgba(154, 111, 44, 0.06) 0%, transparent 60%),
radial-gradient(ellipse at 80% 50%, rgba(154, 111, 44, 0.04) 0%, transparent 60%);
color: #bfb3a2;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.card {
background-color: #2a241c;
border: 1px solid rgba(154, 111, 44, 0.15);
border-radius: 12px;
box-shadow: 0 0 30px rgba(208, 158, 78, 0.06);
padding: 2.5rem;
width: 100%;
max-width: 420px;
text-align: center;
}
h1 {
font-family: 'Quietism', Georgia, 'Times New Roman', serif;
font-size: 1.5rem;
font-weight: 700;
color: #d09e4e;
margin-bottom: 0.75rem;
}
p { line-height: 1.6; margin-bottom: 1rem; }
.subtext { font-size: 0.875rem; color: #6b5f4d; }
.btn-primary {
display: inline-block;
background-color: #9a6f2c;
color: #f0ebe4;
padding: 0.625rem 1.5rem;
border-radius: 6px;
border: none;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: background-color 0.15s;
}
.btn-primary:hover { background-color: #b8862f; }
.btn-secondary {
display: inline-block;
background-color: transparent;
color: #bfb3a2;
padding: 0.625rem 1.5rem;
border-radius: 6px;
border: 1px solid rgba(154, 111, 44, 0.3);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: border-color 0.15s;
}
.btn-secondary:hover { border-color: rgba(154, 111, 44, 0.5); }
.actions { display: flex; gap: 0.75rem; justify-content: center; margin-top: 1.5rem; }
.brand {
margin-top: 2rem;
font-family: 'Quietism', Georgia, 'Times New Roman', serif;
font-size: 0.75rem;
font-variant: small-caps;
letter-spacing: 0.05em;
color: #6b5f4d;
}
.error-detail {
margin-top: 1rem;
background-color: #1a1510;
border: 1px solid rgba(154, 111, 44, 0.1);
border-radius: 6px;
padding: 1rem;
font-family: 'Ubuntu Mono', 'Courier New', monospace;
font-size: 0.75rem;
color: #6b5f4d;
text-align: left;
word-break: break-word;
}
${extraStyles}
</style>
</head>
<body>
<div class="card">
${bodyContent}
<div class="brand">Ghost Guild</div>
</div>
</body>
</html>`;
}
let _provider: InstanceType<typeof Provider> | null = null; let _provider: InstanceType<typeof Provider> | null = null;
export async function getOidcProvider() { export async function getOidcProvider() {
@ -90,7 +210,30 @@ export async function getOidcProvider() {
enabled: process.env.NODE_ENV !== "production", enabled: process.env.NODE_ENV !== "production",
}, },
revocation: { enabled: true }, revocation: { enabled: true },
rpInitiatedLogout: { enabled: true }, rpInitiatedLogout: {
enabled: true,
logoutSource: async (ctx: any, form: string) => {
ctx.body = guildPageShell("Sign Out", `
<h1>Sign Out</h1>
<p>Do you want to sign out of your Ghost Guild session?</p>
<p class="subtext">This will sign you out of the wiki and any other connected services.</p>
${form}
<div class="actions">
<button class="btn-primary" form="op.logoutForm" type="submit" value="yes" name="logout">Yes, sign me out</button>
<a class="btn-secondary" href="https://wiki.ghostguild.org">Stay signed in</a>
</div>
`, "form#op\\.logoutForm { display: none; }");
},
postLogoutSuccessSource: async (ctx: any) => {
ctx.body = guildPageShell("Signed Out", `
<h1>Signed Out</h1>
<p>You have been successfully signed out.</p>
<div class="actions">
<a class="btn-primary" href="https://wiki.ghostguild.org">Return to Wiki</a>
</div>
`);
},
},
}, },
// Mount all OIDC endpoints under /oidc prefix // Mount all OIDC endpoints under /oidc prefix
@ -115,6 +258,20 @@ export async function getOidcProvider() {
}, },
}, },
renderError: async (ctx: any, out: Record<string, string>, _error: Error) => {
const details = Object.entries(out)
.map(([key, value]) => `<strong>${key}:</strong> ${value}`)
.join("<br>");
ctx.body = guildPageShell("Something Went Wrong", `
<h1>Something Went Wrong</h1>
<p>An error occurred during authentication. Please try again.</p>
<div class="error-detail">${details}</div>
<div class="actions">
<a class="btn-primary" href="https://wiki.ghostguild.org">Return to Wiki</a>
</div>
`);
},
// Allow Outline to use PKCE but don't require it // Allow Outline to use PKCE but don't require it
pkce: { pkce: {
required: () => false, required: () => false,