diff --git a/README.md b/README.md index 9050f61..a493d22 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ Missing vanilla assets are also prepared automatically when a plugin export is r ## Install the capture tools +Maps support up to 24 locations and games choose five at random. Owners can opt into player contributions, with a cumulative 12-panorama limit per player and owner approval followed by moderator review. See [panorama contributions](docs/panorama-contributions.md) for both tool workflows and deployment instructions. + +Contributions check the server address recorded during capture. Owners can correct or clear the map's expected address. Fabric records the connected server automatically; plugin admins must set `server-address` or run `/minegeo server ` before exporting. + Ready-to-install JARs are committed under `public/tools/plugin` and `public/tools/mod` and served by `/downloads`. After pulling an update, replace the Minecraft server's installed JAR and restart that server; pulling the website alone does not update an already installed plugin. - **Server:** Java 21 and Bukkit/Paper 1.21.11. Follow [the plugin guide](docs/server-plugin.md). @@ -43,6 +47,8 @@ The build copies both JARs to their Git-visible download folders. Include those ## Checks +Accounts support display names, email/password login and password recovery. See [account settings and Supabase configuration](docs/account-settings.md). + ```sh npm test npm run test:renderer diff --git a/app/account.css b/app/account.css new file mode 100644 index 0000000..c9b4672 --- /dev/null +++ b/app/account.css @@ -0,0 +1,286 @@ +/* Shared account and authentication surfaces, layered after the Minecraft theme. */ +.auth-page { + padding-top: 48px; + padding-bottom: 72px; +} +.auth-layout { + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: clamp(32px, 7vw, 100px); + align-items: center; + max-width: 1120px; + margin: 0 auto; +} +.auth-story h1 { + font-size: clamp(42px, 4.4vw, 64px); + line-height: 1.05; + margin: 18px 0; +} +.auth-story > p { + color: var(--muted); + max-width: 420px; + line-height: 1.8; + font-size: 15px; +} +.auth-story-map { + position: relative; + height: 210px; + border: 1px solid #4b5c43; + margin: 30px 0 22px; + padding: 26px; + display: flex; + flex-direction: column; + justify-content: space-between; + color: #c0e7a2; + background: + linear-gradient(90deg, #101a1cd9, #11192388), + url("/images/example-map.webp") center/cover; + box-shadow: inset 0 0 70px #09121977; +} +.auth-story-map span { + font: + 24px MineGeoPixel, + monospace; +} +.auth-benefits { + display: flex; + gap: 18px; + flex-wrap: wrap; + color: var(--muted); + font-size: 12px; +} +.auth-benefits span { + display: flex; + align-items: center; + gap: 8px; +} +.auth-benefits svg { + color: var(--green); +} +.auth-layout .auth-card { + width: 100%; + max-width: none; + margin: 0; + padding: 32px; + background: #121c28f5; + border: 1px solid #3a4958; + box-shadow: 0 20px 60px #0003; +} +.auth-card h2 { + font-size: 38px; + line-height: 1.1; + margin: 14px 0 10px; +} +.auth-card .field { + font-size: 13px; + margin: 18px 0; +} +.field small { + display: block; + margin-top: 8px; + font-size: 11px; + color: var(--muted); + line-height: 1.6; +} +.auth-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + background: #0a121c; + padding: 4px; + border: 1px solid var(--line); + margin-bottom: 28px; +} +.auth-tabs button { + padding: 10px 8px; + border: 0; + color: var(--muted); + font-size: 13px; + cursor: pointer; +} +.auth-tabs button[aria-pressed="true"] { + color: var(--text); + background: #223342; + box-shadow: 0 2px 7px #0004; +} +.auth-recovery { + display: block; + margin: -4px 0 22px auto; + font-size: 12px; +} +.auth-card .text-link { + cursor: pointer; + color: var(--green); +} +.auth-alternative { + border-top: 1px solid var(--line); + padding-top: 20px; + margin-top: 24px; + text-align: center; + display: grid; + gap: 8px; + font-size: 12px; + color: var(--muted); +} +.auth-footnote { + color: var(--muted); + font-size: 11px; + line-height: 1.6; + text-align: center; + margin: 24px 0 0; +} +.auth-back { + display: block; + margin: 22px 0 0; + font-size: 12px; +} +.auth-card input, +.settings-card input { + min-height: 46px; + border-radius: 3px; +} +.reset-card { + max-width: 520px; + padding: 32px; + margin: 24px auto; +} +.reset-card h1 { + font-size: 46px; +} +.account-page-heading { + margin: 28px 0 36px; +} +.account-page-heading h1 { + margin: 10px 0; +} +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + align-items: start; +} +.settings-card { + border: 1px solid #3a4958; + background: #121c28e8; + padding: 28px; + box-shadow: 0 12px 30px #0002; + min-width: 0; +} +.settings-heading { + display: flex; + align-items: flex-start; + gap: 14px; + border-bottom: 1px solid var(--line); + padding-bottom: 20px; +} +.settings-heading > svg { + margin-top: 6px; + color: var(--green); + flex: none; +} +.settings-heading h2 { + font-size: 32px; + line-height: 1.2; + margin-bottom: 6px; +} +.settings-heading p { + font-size: 12px; + margin: 0; +} +.account-email { + display: flex; + gap: 12px; + margin: 24px 0; + padding: 16px; + border: 1px solid var(--line); + background: #0d1520; + min-width: 0; +} +.account-email svg { + flex: none; + color: var(--muted); + margin-top: 4px; +} +.account-email div { + min-width: 0; + display: grid; + gap: 5px; +} +.account-email span, +.account-email small { + font-size: 11px; + color: var(--muted); +} +.account-email strong { + font-weight: 400; + overflow-wrap: anywhere; +} +.settings-recovery { + margin-top: 28px; + padding-top: 24px; + border-top: 1px solid var(--line); +} +.settings-recovery h3 { + font-size: 24px; + margin-bottom: 8px; +} +.settings-recovery p { + font-size: 12px; +} +.map-credit { + color: var(--muted); + font-size: 12px; + margin: 14px 0 0; +} +.dashboard-toolbar .inline-actions { + flex-wrap: wrap; + justify-content: flex-end; +} +@media (max-width: 850px) { + .auth-layout { + grid-template-columns: 1fr; + max-width: 520px; + gap: 24px; + } + .auth-story-map, + .auth-benefits { + display: none; + } + .auth-story h1 { + font-size: 42px; + margin: 12px 0; + } + .auth-story > p { + margin-bottom: 0; + font-size: 13px; + } + .settings-grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 500px) { + .auth-page { + padding-top: 28px; + } + .auth-layout .auth-card, + .settings-card, + .reset-card { + padding: 22px; + } + .auth-story h1 { + font-size: 36px; + } + .auth-card h2 { + font-size: 32px; + } + .dashboard-toolbar { + flex-direction: column; + } + .dashboard-toolbar .inline-actions { + justify-content: flex-start; + } + .preview-toolbar { + padding: 10px 16px; + gap: 8px; + } +} diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index 290218e..e0cced0 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -1,13 +1,53 @@ import { NextRequest, NextResponse } from "next/server"; import { authClient } from "@/lib/supabase"; import { requestOrigin } from "@/lib/request-origin"; +import { authDestination } from "@/shared/account"; +import { createRecoveryGrant, recoveryCookie } from "@/lib/password-recovery"; +import { accountProfile } from "@/lib/profile"; export async function GET(request: NextRequest) { const origin = requestOrigin(request, process.env.MINEGEO_URL); const code = request.nextUrl.searchParams.get("code"); - if (code) { + const tokenHash = request.nextUrl.searchParams.get("token_hash"); + const type = request.nextUrl.searchParams.get("type"); + const next = authDestination(request.nextUrl.searchParams.get("next")); + if ( + code || + (tokenHash && + (type === "email" || type === "recovery" || type === "magiclink")) + ) { const client = await authClient(); - const { error } = await client.auth.exchangeCodeForSession(code); - if (!error) return NextResponse.redirect(new URL("/dashboard", origin)); + const { data, error } = code + ? await client.auth.exchangeCodeForSession(code) + : await client.auth.verifyOtp({ + token_hash: tokenHash!, + type: type as "email" | "recovery" | "magiclink", + }); + if (!error && data.user) { + await accountProfile(data.user); + const destination = type === "recovery" ? "/auth/reset-password" : next; + const response = NextResponse.redirect(new URL(destination, origin)); + if (destination === "/auth/reset-password") { + const { data: claims } = await client.auth.getClaims(); + const sessionId = claims?.claims.session_id; + if (typeof sessionId === "string") + response.cookies.set( + recoveryCookie, + createRecoveryGrant( + data.user.id, + sessionId, + process.env.SUPABASE_SECRET_KEY!, + ), + { + httpOnly: true, + sameSite: "lax", + secure: new URL(origin).protocol === "https:", + path: "/", + maxAge: 600, + }, + ); + } + return response; + } } - return NextResponse.redirect(new URL("/dashboard?auth=failed", origin)); + return NextResponse.redirect(new URL("/log-in?auth=failed", origin)); } diff --git a/app/auth/confirm/route.ts b/app/auth/confirm/route.ts new file mode 100644 index 0000000..57ff4bf --- /dev/null +++ b/app/auth/confirm/route.ts @@ -0,0 +1 @@ +export { GET } from "../callback/route"; diff --git a/app/auth/reset-password/page.tsx b/app/auth/reset-password/page.tsx new file mode 100644 index 0000000..7b389d3 --- /dev/null +++ b/app/auth/reset-password/page.tsx @@ -0,0 +1,56 @@ +import { cookies } from "next/headers"; +import Link from "next/link"; +import { Header, Footer } from "@/components/Shell"; +import { PasswordForm } from "@/components/PasswordForm"; +import { authClient, user } from "@/lib/supabase"; +import { recoveryCookie, verifyRecoveryGrant } from "@/lib/password-recovery"; +export const metadata = { title: "Reset password" }; +export default async function ResetPassword() { + const [account, jar, client] = await Promise.all([ + user(), + cookies(), + authClient(), + ]); + const { data } = account ? await client.auth.getClaims() : { data: null }; + const sessionId = data?.claims.session_id; + const valid = + account && + typeof sessionId === "string" && + verifyRecoveryGrant( + jar.get(recoveryCookie)?.value, + account.id, + sessionId, + process.env.SUPABASE_SECRET_KEY!, + ); + return ( + <> +
+
+
+ ACCOUNT RECOVERY +

A fresh start.

+ {valid ? ( + <> +

Choose a new password for your account.

+ + + ) : ( + <> +

+ This reset link has expired or has already been used. Request a + fresh link to continue. +

+ + Request a new reset link → + + + )} +
+
+