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:
Super User
2026-05-18 13:46:13 +02:00
commit be1c4fe5f9
96 changed files with 49849 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
import AccessPageClient from "@/components/pam/AccessPageClient";
import { requireAuth } from "@/auth";
export const metadata = {
title: "Privileged Access — GSC My",
};
export default async function AccessPage({
searchParams,
}: {
searchParams: Promise<{ need?: string }>;
}) {
await requireAuth();
const sp = await searchParams;
return <AccessPageClient needRole={sp.need ?? null} />;
}

133
src/app/(my)/agent/page.tsx Normal file
View File

@@ -0,0 +1,133 @@
import https from "node:https";
import fs from "node:fs";
import { getAuthenticatedUser } from "@/auth";
import { AgentSettingsForm } from "@/components/settings/AgentSettingsForm";
import type { AgentConfig } from "@gsc/chat";
const OPS_API_URL = process.env.OPS_API_URL || "https://172.17.8.20:8443";
const OPS_API_KEY = process.env.OPS_API_KEY || "";
let _tlsAgent: https.Agent | null = null;
function getTlsAgent(): https.Agent {
if (!_tlsAgent) {
_tlsAgent = new https.Agent({
cert: fs.readFileSync("/etc/mcp-certs/client.crt"),
key: fs.readFileSync("/etc/mcp-certs/client.key"),
ca: fs.readFileSync("/etc/mcp-certs/ca-chain.crt"),
rejectUnauthorized: false,
});
}
return _tlsAgent;
}
function opsApiGet(path: string): Promise<string> {
const url = new URL(`${OPS_API_URL}${path}`);
return new Promise((resolve, reject) => {
const req = https.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method: "GET",
agent: getTlsAgent(),
headers: {
"X-API-Key": OPS_API_KEY,
"Content-Type": "application/json",
},
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
const body = Buffer.concat(chunks).toString();
if (res.statusCode && res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}`));
} else {
resolve(body);
}
});
}
);
req.on("error", reject);
req.end();
});
}
async function getAgentConfig(userId?: string, tenantId?: string): Promise<AgentConfig> {
if (!userId || !tenantId || !OPS_API_KEY) {
return defaultConfig();
}
try {
const data = await opsApiGet(
`/api/v1/agents/me?userId=${userId}&tenantId=${tenantId}`
);
const envelope = JSON.parse(data);
if (envelope.data?.config) {
return envelope.data.config as AgentConfig;
}
} catch {
// Fall through to default
}
return defaultConfig();
}
function defaultConfig(): AgentConfig {
return {
id: "default",
userId: "",
agentName: "",
userName: "",
activePersonaId: "default",
personas: [
{
id: "default",
name: "Default Assistant",
archetype: "Balanced",
voiceTone: "Professional",
mbti: "INTJ",
personality: {
openness: 50,
conscientiousness: 50,
extraversion: 50,
agreeableness: 50,
neuroticism: 50,
},
positiveRules: [
"Be helpful and concise",
"Provide accurate information",
"Respect user privacy",
],
negativeRules: [
"Do not share personal data",
"Do not make assumptions about intent",
],
backstory: "",
worldBuilding: "",
topicalRails: [],
defaultModel: "claude-3-5-sonnet",
temperature: 0.7,
maxTokensPerTurn: 2048,
guardrailsConfig: {
maxResponseLength: 4000,
allowCodeExecution: false,
allowExternalLinks: true,
},
status: "active",
},
],
memorySettings: {
retentionDays: 30,
},
};
}
export default async function AgentSettingsPage() {
const user = await getAuthenticatedUser();
const config = await getAgentConfig(user?.gscUserId, user?.tenantId);
return (
<AgentSettingsForm initialConfig={config} userGivenName={user?.givenName || ""} />
);
}

View File

@@ -0,0 +1,133 @@
import { getAuthenticatedUser } from "@/auth";
import { getLoginEvents, getUserHas2FA, getUserSessions } from "@/lib/keycloak";
import { getUserActivity } from "@/database/activity";
import { getUserByGscsid } from "@/database/users";
import UserAnalytics from "@/components/account/UserAnalytics";
export default async function AnalyticsPage() {
const user = await getAuthenticatedUser();
let loginSessions: Array<{
id: string;
loginTime: string;
ipAddress: string;
device: string;
location: string;
status: "success" | "failed";
}> = [];
let recentActivity: Array<{
id: string;
action: string;
target: string;
timestamp: string;
icon: string;
color: string;
}> = [];
let memberSince: string | null = null;
let twoFactorEnabled = false;
let activeSessionCount = 0;
// Fetch Keycloak login events, 2FA status, and active sessions
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;
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,
}));
} catch (err) {
console.warn("[analytics page] Keycloak fetch error:", err);
}
}
// Fetch activity log and member-since date from DB
if (user?.gscUserId) {
try {
const activities = await getUserActivity(user.gscUserId, { limit: 20 });
recentActivity = activities.map((a) => ({
id: a.id,
action: a.action,
target: a.target || "",
timestamp: a.createdAt.toISOString(),
icon: activityIcon(a.action),
color: activityColor(a.action),
}));
} catch (err) {
console.warn("[analytics page] Activity fetch error:", err);
}
// Get member since
if (user.gscsid) {
try {
const dbUser = await getUserByGscsid(user.gscsid);
if (dbUser?.createdAt) {
memberSince = dbUser.createdAt.toISOString();
}
} catch {
// ignore
}
}
}
return (
<UserAnalytics
userName={user?.displayName || "User"}
userEmail={user?.email || ""}
loginSessions={loginSessions}
recentActivity={recentActivity}
memberSince={memberSince}
twoFactorEnabled={twoFactorEnabled}
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";
}
function activityIcon(action: string): string {
const map: Record<string, string> = {
Opened: "ph-house", Configured: "ph-gear", Browsed: "ph-storefront",
Viewed: "ph-eye", Updated: "ph-pencil", Accessed: "ph-envelope",
Created: "ph-plus", Deleted: "ph-trash", Login: "ph-sign-in",
};
return map[action] || "ph-activity";
}
function activityColor(action: string): string {
const map: Record<string, string> = {
Opened: "primary", Configured: "info", Browsed: "success",
Viewed: "secondary", Updated: "warning", Accessed: "primary",
Created: "success", Deleted: "danger", Login: "success",
};
return map[action] || "secondary";
}

73
src/app/(my)/layout.tsx Normal file
View File

@@ -0,0 +1,73 @@
import { AdminShell, type DbMenuItem } from "@gsc/web-kit/chrome";
import sidebarMenuJson from "@/config/sidebar-menu.json";
import { getAuthenticatedUser, requireAuth } from "@/auth";
import { brand } from "@/config/brand";
import ActiveGrantsWidget from "@/components/pam/ActiveGrantsWidget";
type LegacySidebarItem = {
id: number;
icon?: string;
name: string;
url: string;
key: string;
submenulvl1?: { name: string; url: string; key: string; icon?: string }[];
};
// Map the legacy JSON-driven sidebar onto the kit's DbMenuItem shape.
// (When gscMy gets its own admin.menu_items rows we can drop the JSON
// and load from DB the same way gscAdmin does.)
function toDbMenuItem(item: LegacySidebarItem, order: number): DbMenuItem {
return {
id: String(item.id),
key: item.key,
translationKey: item.key,
url: item.url,
icon: item.icon ?? null,
sortOrder: order,
isActive: true,
isSystemRequired: false,
children:
item.submenulvl1?.map((c, i) => ({
id: `${item.id}.${i + 1}`,
key: c.key,
translationKey: c.key,
url: c.url,
icon: c.icon ?? null,
sortOrder: i,
isActive: true,
isSystemRequired: false,
})) ?? [],
};
}
const sidebar = (sidebarMenuJson as LegacySidebarItem[]).map(toDbMenuItem);
export default async function MyGroupLayout({
children,
}: {
children: React.ReactNode;
}) {
await requireAuth();
const user = await getAuthenticatedUser();
return (
<AdminShell
menus={{ sidebar, topbar: [], subbar: [] }}
apps={[]}
user={{
displayName: user?.displayName || user?.email || "",
email: user?.email ?? "",
}}
brand={brand}
features={{
chat: false,
activityPanel: false,
}}
slots={{
navbarExtras: <ActiveGrantsWidget />,
}}
>
{children}
</AdminShell>
);
}

48
src/app/(my)/page.tsx Normal file
View File

@@ -0,0 +1,48 @@
import Link from "next/link";
const sections = [
{ title: "Profile", description: "View your account information and group memberships", icon: "ph-user-circle", href: "/profile", color: "primary" },
{ title: "Settings", description: "Language, timezone, theme, notifications, email and calendar", icon: "ph-gear", href: "/settings", color: "secondary" },
{ title: "Security", description: "Two-factor authentication, login sessions, and security log", icon: "ph-shield-check", href: "/security", color: "success" },
{ title: "Privacy", description: "Profile visibility, data tracking, and communication preferences", icon: "ph-lock-key", href: "/privacy", color: "warning" },
{ title: "Analytics", description: "Login history, app usage, and activity log", icon: "ph-chart-line-up", href: "/analytics", color: "info" },
{ title: "Voice", description: "Call forwarding, voicemail, music on hold, and extension settings", icon: "ph-phone", href: "/voice", color: "danger" },
{ title: "AI Agent", description: "Configure your personal AI assistant persona and behavior", icon: "ph-robot", href: "/agent", color: "dark" },
];
export default function MyDashboard() {
return (
<>
<div className="mb-4">
<h4 className="mb-1">
<i className="ph-sliders-horizontal me-2"></i>
My Settings
</h4>
<p className="text-muted mb-0">Manage your personal account settings and preferences.</p>
</div>
<div className="row">
{sections.map((section) => (
<div key={section.href} className="col-md-6 col-xl-4 mb-3">
<Link href={section.href} className="text-decoration-none">
<div className="card h-100 border-0 shadow-sm card-hover">
<div className="card-body">
<div className="d-flex align-items-center mb-3">
<div
className={`bg-${section.color} bg-opacity-10 text-${section.color} rounded-pill d-flex align-items-center justify-content-center me-3`}
style={{ width: "48px", height: "48px" }}
>
<i className={`${section.icon} fs-4`}></i>
</div>
<h5 className="mb-0 text-body">{section.title}</h5>
</div>
<p className="text-muted mb-0 small">{section.description}</p>
</div>
</div>
</Link>
</div>
))}
</div>
</>
);
}

View File

@@ -0,0 +1,30 @@
import { getAuthenticatedUser } from "@/auth";
import { getUserEffectiveSettings } from "@/database/settings";
import UserPrivacy from "@/components/account/UserPrivacy";
export default async function PrivacyPage() {
const user = await getAuthenticatedUser();
let initialSettings: Record<string, unknown> = {};
if (user?.gscUserId) {
try {
const effective = await getUserEffectiveSettings(
user.gscUserId,
user.tenantId,
user.gscCustomerId,
["user.privacy", "user.security"]
);
for (const cat of Object.values(effective)) {
for (const [key, setting] of Object.entries(cat)) {
initialSettings[key] = setting.value;
}
}
} catch (err) {
console.warn("[privacy page] Failed to load settings:", err);
}
}
return <UserPrivacy initialSettings={initialSettings} />;
}

View File

@@ -0,0 +1,18 @@
import { getAuthenticatedUser } from "@/auth";
import UserProfile from "@/components/account/UserProfile";
export default async function ProfilePage() {
const user = await getAuthenticatedUser();
return (
<UserProfile
displayName={user?.displayName || "User"}
givenName={user?.givenName || ""}
familyName={user?.familyName || ""}
email={user?.email || ""}
groups={user?.groups || []}
keycloakId={user?.keycloakId || ""}
tenantId={user?.tenantId || ""}
/>
);
}

View 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";
}

View File

@@ -0,0 +1,38 @@
import { getAuthenticatedUser } from "@/auth";
import { getUserEffectiveSettings } from "@/database/settings";
import AccountSettings from "@/components/account/AccountSettings";
const SETTINGS_CATEGORIES = ["user.general", "user.notifications", "user.email", "user.calendar"];
export default async function SettingsPage() {
const user = await getAuthenticatedUser();
let initialSettings: Record<string, unknown> = {};
if (user?.gscUserId) {
try {
const effective = await getUserEffectiveSettings(
user.gscUserId,
user.tenantId,
user.gscCustomerId,
SETTINGS_CATEGORIES
);
for (const cat of Object.values(effective)) {
for (const [key, setting] of Object.entries(cat)) {
initialSettings[key] = setting.value;
}
}
} catch (err) {
console.warn("[settings page] Failed to load settings:", err);
}
}
return (
<AccountSettings
displayName={user?.displayName || "User"}
email={user?.email || ""}
initialSettings={initialSettings}
/>
);
}

View File

@@ -0,0 +1,42 @@
import { getAuthenticatedUser } from "@/auth";
import { getUserExtension, getFollowMe, getVoicemailBox, getMohClasses } from "@/database/pbx";
import UserVoiceSettings from "@/components/account/UserVoiceSettings";
export default async function VoicePage() {
const user = await getAuthenticatedUser();
let extension = null;
let followMe = null;
let voicemailBox = null;
let mohClasses: Array<{ id: string; name: string; isDefault: boolean }> = [];
if (user?.gscUserId && user.tenantId) {
try {
extension = await getUserExtension(user.gscUserId, user.tenantId);
if (extension) {
const [fm, vm, moh] = await Promise.all([
getFollowMe(extension.id),
getVoicemailBox(extension.id),
getMohClasses(user.tenantId),
]);
followMe = fm;
voicemailBox = vm;
mohClasses = moh;
}
} catch (err) {
console.warn("[voice page] PBX fetch error:", err);
}
}
return (
<UserVoiceSettings
extension={extension}
followMe={followMe}
voicemailBox={voicemailBox}
mohClasses={mohClasses}
tenantId={user?.tenantId || ""}
userEmail={user?.email || ""}
/>
);
}