chore: bootstrap gscMy on @gsc/web-kit + PAM/JIT request flow
Initial commit for gscMy carved out as its own repo (was tracked
loosely under the monorepo's web/ which is gitignored).
What this contains:
- Auth: next-auth v5 via @gsc/web-kit createAuth (Keycloak only,
identity sourced from claims, no admin.users writes)
- Chrome: @gsc/web-kit AdminShell — replaces the legacy MyShell.
Sidebar JSON config carried over and mapped to DbMenuItem.
- Middleware: createAuthMiddleware. Public: /access-denied,
/auth/keycloak, /signed-out, /api/health, /api/pam/approve.
- RP-initiated signout at /api/auth/signout → Keycloak end_session →
/signed-out (mirrors gscAdmin).
- Phosphor-iconned access-denied + signed-out landing pages.
PAM/JIT request flow (ported from gscAdmin's pre-strip git history):
- /access page (Active + Eligible tables, request modal with
duration slider + justification + optional MFA)
- API: /api/pam/{eligible, active, audit, request, approve/:token,
revoke/:id}
- src/lib/{authz, pam, pam-mail, pam-mfa}.ts — same files as
gscAdmin had before the strip. PAM tables (admin.privilege_*)
are shared with gscAdmin; gscMy uses the same Prisma model defs.
- Top-bar widget shows active grants with countdown + revoke.
Build/Deploy: Dockerfile (monorepo-root context), k8s manifests for
my.gosec.internal, self-signed TLS placeholder, DNS A record.
Keycloak gsc-my client extended to include my.gosec.internal/* in
redirect_uris + web_origins.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
117
src/app/(my)/security/page.tsx
Normal file
117
src/app/(my)/security/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { getAuthenticatedUser } from "@/auth";
|
||||
import { getUserEffectiveSettings } from "@/database/settings";
|
||||
import { getLoginEvents, getUserSessions, getUserHas2FA } from "@/lib/keycloak";
|
||||
import AccountSecurity from "@/components/account/AccountSecurity";
|
||||
|
||||
export default async function SecurityPage() {
|
||||
const user = await getAuthenticatedUser();
|
||||
|
||||
let loginSessions: Array<{
|
||||
id: string;
|
||||
loginTime: string;
|
||||
ipAddress: string;
|
||||
device: string;
|
||||
location: string;
|
||||
status: "success" | "failed";
|
||||
}> = [];
|
||||
let securityEvents: Array<{
|
||||
id: string;
|
||||
event: string;
|
||||
detail: string;
|
||||
timestamp: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}> = [];
|
||||
let twoFactorEnabled = false;
|
||||
let loginNotifications = true;
|
||||
let activeSessionCount = 0;
|
||||
|
||||
if (user?.keycloakId) {
|
||||
try {
|
||||
const [events, has2FA, sessions] = await Promise.all([
|
||||
getLoginEvents(user.keycloakId, 20),
|
||||
getUserHas2FA(user.keycloakId),
|
||||
getUserSessions(user.keycloakId),
|
||||
]);
|
||||
|
||||
twoFactorEnabled = has2FA;
|
||||
activeSessionCount = sessions.length;
|
||||
|
||||
// Map Keycloak events to login sessions
|
||||
loginSessions = events.map((e, i) => ({
|
||||
id: String(i),
|
||||
loginTime: new Date(e.time).toISOString(),
|
||||
ipAddress: e.ipAddress || "Unknown",
|
||||
device: e.details?.user_agent
|
||||
? parseUserAgent(e.details.user_agent)
|
||||
: "Unknown",
|
||||
location: "—",
|
||||
status: e.type === "LOGIN" ? "success" as const : "failed" as const,
|
||||
}));
|
||||
|
||||
// Map to security events timeline
|
||||
securityEvents = events.slice(0, 10).map((e, i) => ({
|
||||
id: String(i),
|
||||
event: e.type === "LOGIN" ? "Login" : "Failed Login",
|
||||
detail: e.type === "LOGIN"
|
||||
? `Successful login from ${e.ipAddress || "unknown"}`
|
||||
: `Failed login attempt: ${e.error || "invalid credentials"}`,
|
||||
timestamp: new Date(e.time).toISOString(),
|
||||
icon: e.type === "LOGIN" ? "ph-sign-in" : "ph-warning",
|
||||
color: e.type === "LOGIN" ? "success" : "danger",
|
||||
}));
|
||||
} catch (err) {
|
||||
console.warn("[security page] Keycloak fetch error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Load loginNotifications preference from DB
|
||||
if (user?.gscUserId) {
|
||||
try {
|
||||
const effective = await getUserEffectiveSettings(
|
||||
user.gscUserId,
|
||||
user.tenantId,
|
||||
user.gscCustomerId,
|
||||
["user.security"]
|
||||
);
|
||||
const secSettings = effective["user.security"];
|
||||
if (secSettings?.loginNotifications) {
|
||||
loginNotifications = secSettings.loginNotifications.value as boolean;
|
||||
}
|
||||
} catch {
|
||||
// Use default
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountSecurity
|
||||
displayName={user?.displayName || "User"}
|
||||
email={user?.email || ""}
|
||||
loginSessions={loginSessions}
|
||||
securityEvents={securityEvents}
|
||||
twoFactorEnabled={twoFactorEnabled}
|
||||
initialLoginNotifications={loginNotifications}
|
||||
activeSessionCount={activeSessionCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function parseUserAgent(ua: string): string {
|
||||
if (ua.includes("Chrome") && !ua.includes("Edge")) {
|
||||
if (ua.includes("Windows")) return "Chrome on Windows";
|
||||
if (ua.includes("Mac")) return "Chrome on macOS";
|
||||
if (ua.includes("Linux")) return "Chrome on Linux";
|
||||
return "Chrome";
|
||||
}
|
||||
if (ua.includes("Firefox")) {
|
||||
if (ua.includes("Windows")) return "Firefox on Windows";
|
||||
if (ua.includes("Mac")) return "Firefox on macOS";
|
||||
return "Firefox";
|
||||
}
|
||||
if (ua.includes("Safari") && !ua.includes("Chrome")) {
|
||||
if (ua.includes("iPhone") || ua.includes("iPad")) return "Safari on iOS";
|
||||
return "Safari on macOS";
|
||||
}
|
||||
if (ua.includes("Edge")) return "Edge on Windows";
|
||||
return "Unknown";
|
||||
}
|
||||
Reference in New Issue
Block a user