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:
16
src/app/(my)/access/page.tsx
Normal file
16
src/app/(my)/access/page.tsx
Normal 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
133
src/app/(my)/agent/page.tsx
Normal 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 || ""} />
|
||||
);
|
||||
}
|
||||
133
src/app/(my)/analytics/page.tsx
Normal file
133
src/app/(my)/analytics/page.tsx
Normal 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
73
src/app/(my)/layout.tsx
Normal 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
48
src/app/(my)/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
src/app/(my)/privacy/page.tsx
Normal file
30
src/app/(my)/privacy/page.tsx
Normal 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} />;
|
||||
}
|
||||
18
src/app/(my)/profile/page.tsx
Normal file
18
src/app/(my)/profile/page.tsx
Normal 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 || ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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";
|
||||
}
|
||||
38
src/app/(my)/settings/page.tsx
Normal file
38
src/app/(my)/settings/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
src/app/(my)/voice/page.tsx
Normal file
42
src/app/(my)/voice/page.tsx
Normal 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 || ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user