Skip to content

Commit 7d54933

Browse files
committed
sso implementation;
added my geoids page;
1 parent 6a3581c commit 7d54933

11 files changed

Lines changed: 298 additions & 13 deletions

File tree

app/.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
GEOID_BASE_URL=https://data.review.fao.org/geoid
22
GEOID_ADMIN_TOKEN=<token-if-required>
3-
MAX_REQUEST_BODY_SIZE_KB=1024
3+
MAX_REQUEST_BODY_SIZE_KB=1024
4+
5+
# SSO (optional) - Authorization Code + PKCE via Auth.js against Keycloak.
6+
KEYCLOAK_ISSUER=https://aip-sso.review.fao.org/realms/fao-aip-auth-review
7+
KEYCLOAK_CLIENT_ID=geoid-fe
8+
KEYCLOAK_CLIENT_SECRET=
9+
KEYCLOAK_SCOPE=openid email profile

app/package-lock.json

Lines changed: 94 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"clsx": "^2.1.1",
1414
"lucide-react": "^1.14.0",
1515
"next": "16.2.6",
16+
"next-auth": "^5.0.0-beta.31",
1617
"react": "19.2.4",
1718
"react-dom": "19.2.4",
1819
"server-only": "^0.0.1",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { handlers } from "@/auth"
2+
3+
export const { GET, POST } = handlers

app/src/app/layout.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { Metadata } from "next";
22
import { Manrope } from "next/font/google";
3+
import { SessionProvider } from "next-auth/react";
4+
import { auth } from "@/auth";
35
import { ThemeProvider } from "@/components/layout/theme-provider";
46
import { Navbar } from "@/components/layout/navbar";
57
import { Footer } from "@/components/layout/footer";
@@ -17,11 +19,13 @@ const manrope = Manrope({
1719
display: "swap",
1820
});
1921

20-
export default function RootLayout({
22+
export default async function RootLayout({
2123
children,
2224
}: Readonly<{
2325
children: React.ReactNode;
2426
}>) {
27+
const session = await auth();
28+
2529
return (
2630
<html lang="en" className={`h-full antialiased ${manrope.variable}`} suppressHydrationWarning>
2731
<head>
@@ -32,13 +36,15 @@ export default function RootLayout({
3236
/>
3337
</head>
3438
<body className="min-h-full flex flex-col bg-bg text-text-primary transition-colors">
35-
<ThemeProvider>
36-
<Navbar />
37-
<main className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 py-8">
38-
{children}
39-
</main>
40-
<Footer />
41-
</ThemeProvider>
39+
<SessionProvider session={session}>
40+
<ThemeProvider>
41+
<Navbar />
42+
<main className="flex min-h-0 flex-1 flex-col overflow-hidden px-6 py-8">
43+
{children}
44+
</main>
45+
<Footer />
46+
</ThemeProvider>
47+
</SessionProvider>
4248
</body>
4349
</html>
4450
);

app/src/app/my-geoids/page.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { auth } from "@/auth";
2+
import { requireGeoidClient } from "@/lib/geoid/client";
3+
import type { PlaceRecord } from "@/lib/geoid/types";
4+
import { Alert } from "@/components/ui/alert";
5+
import { Link } from "@/components/ui/link";
6+
import { cardBase } from "@/components/ui/styles";
7+
8+
export const dynamic = "force-dynamic";
9+
10+
const PAGE_SIZE = 50;
11+
12+
export default async function MyGeoidsPage({
13+
searchParams,
14+
}: {
15+
searchParams: Promise<{ offset?: string }>;
16+
}) {
17+
const session = await auth();
18+
const offset = Number((await searchParams).offset) || 0;
19+
20+
return (
21+
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col min-h-0 gap-5">
22+
<h1 className="text-2xl font-semibold tracking-tight text-text-primary">My GeoIDs</h1>
23+
24+
{!session?.user ? (
25+
<p className="text-sm text-text-muted">Sign in to view the GeoIDs you&apos;ve registered.</p>
26+
) : (
27+
<MyGeoidsList offset={offset} />
28+
)}
29+
</div>
30+
);
31+
}
32+
33+
async function MyGeoidsList({ offset }: { offset: number }) {
34+
let records: PlaceRecord[];
35+
try {
36+
const client = await requireGeoidClient();
37+
records = await client.listMyGeoids({ limit: PAGE_SIZE, offset });
38+
} catch (err) {
39+
return <Alert type="error" message={(err as Error).message} />;
40+
}
41+
42+
if (records.length === 0 && offset === 0) {
43+
return <p className="text-sm text-text-muted">You haven&apos;t registered any GeoIDs yet.</p>;
44+
}
45+
46+
return (
47+
<div className="flex min-h-0 flex-1 flex-col gap-3">
48+
<div className={`min-h-0 flex-1 overflow-auto ${cardBase}`}>
49+
<table className="w-full text-left text-sm">
50+
<thead className="sticky top-0 bg-surface text-text-muted">
51+
<tr className="border-b border-border">
52+
<th className="px-3 py-2 font-medium">GeoID</th>
53+
<th className="px-3 py-2 font-medium">Collection</th>
54+
<th className="px-3 py-2 font-medium">External ID</th>
55+
<th className="px-3 py-2 font-medium">Registered</th>
56+
</tr>
57+
</thead>
58+
<tbody>
59+
{records.map((record) => (
60+
<tr key={record.geoid} className="border-b border-border last:border-0">
61+
<td className="px-3 py-2 font-mono">
62+
<Link href={record.uri}>{record.geoid}</Link>
63+
</td>
64+
<td className="px-3 py-2 text-text-primary">{record.collection}</td>
65+
<td className="px-3 py-2 text-text-muted">{record.external_id ?? "—"}</td>
66+
<td className="px-3 py-2 text-text-muted">
67+
{new Date(record.created_at).toLocaleString()}
68+
</td>
69+
</tr>
70+
))}
71+
</tbody>
72+
</table>
73+
</div>
74+
<div className="flex justify-between text-sm">
75+
{offset > 0 ? (
76+
<Link href={`/my-geoids?offset=${Math.max(0, offset - PAGE_SIZE)}`}>Previous</Link>
77+
) : (
78+
<span />
79+
)}
80+
{records.length === PAGE_SIZE && (
81+
<Link href={`/my-geoids?offset=${offset + PAGE_SIZE}`}>Next</Link>
82+
)}
83+
</div>
84+
</div>
85+
);
86+
}

app/src/auth.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import NextAuth from "next-auth"
2+
import type { Provider } from "next-auth/providers"
3+
4+
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET || undefined
5+
6+
const keycloak: Provider = {
7+
id: "fao",
8+
name: "FAO SSO",
9+
type: "oidc",
10+
issuer: process.env.KEYCLOAK_ISSUER,
11+
clientId: process.env.KEYCLOAK_CLIENT_ID,
12+
clientSecret,
13+
// Public client: no client secret, authenticate with PKCE only.
14+
client: clientSecret ? undefined : { token_endpoint_auth_method: "none" },
15+
authorization: { params: { scope: process.env.KEYCLOAK_SCOPE } },
16+
}
17+
18+
export const { handlers, signIn, signOut, auth } = NextAuth({
19+
providers: [keycloak],
20+
trustHost: true,
21+
callbacks: {
22+
jwt({ token, account }) {
23+
if (account?.access_token) token.accessToken = account.access_token
24+
return token
25+
},
26+
session({ session, token }) {
27+
if (typeof token.accessToken === "string") session.accessToken = token.accessToken
28+
return session
29+
},
30+
},
31+
})

app/src/components/layout/navbar.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
import { useState } from "react";
44
import Link from "next/link";
55
import Image from "next/image";
6-
import { Moon, Settings, Sun } from "lucide-react";
6+
import { useSession, signIn, signOut } from "next-auth/react";
7+
import { LogIn, LogOut, Moon, Settings, Sun } from "lucide-react";
78
import { Button } from "@/components/ui/button";
89
import { useTheme } from "@/components/layout/theme-provider";
910
import { EnvSettingsModal } from "@/components/layout/env-settings-modal";
1011

1112
export function Navbar() {
1213
const { theme, toggleTheme } = useTheme();
1314
const [settingsOpen, setSettingsOpen] = useState(false);
15+
const { data: session } = useSession();
1416

1517
return (
1618
<nav className="sticky top-0 z-[250] flex h-14 items-center gap-4 border-b border-border bg-bg px-8">
@@ -46,6 +48,23 @@ export function Navbar() {
4648
{theme === "dark" ? <Sun className="size-4" /> : <Moon className="size-4" />}
4749
</Button>
4850

51+
{session?.user ? (
52+
<>
53+
<Button variant="ghost" size="sm" nativeButton={false} render={<Link href="/my-geoids" />}>
54+
My GeoIDs
55+
</Button>
56+
<Button variant="ghost" size="sm" onClick={() => signOut()} aria-label="Sign out">
57+
<LogOut className="size-4" />
58+
{session.user.name ?? session.user.email}
59+
</Button>
60+
</>
61+
) : (
62+
<Button variant="ghost" size="sm" onClick={() => signIn("fao")} aria-label="Sign in">
63+
<LogIn className="size-4" />
64+
Sign in
65+
</Button>
66+
)}
67+
4968
<EnvSettingsModal open={settingsOpen} onOpenChange={setSettingsOpen} />
5069
</nav>
5170
);

0 commit comments

Comments
 (0)