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>
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
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";
|
|
}
|