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

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.next
.turbo
.git
.env.local
.env.development.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
tsconfig.tsbuildinfo
README.md

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# NextAuth
AUTH_SECRET="your-auth-secret-generate-with-openssl-rand-base64-32"
AUTH_TRUST_HOST=true
AUTH_URL="https://my.gosec.cloud"
# Keycloak OIDC
AUTH_KEYCLOAK_ID="gsc-my"
AUTH_KEYCLOAK_SECRET="your-keycloak-client-secret"
AUTH_KEYCLOAK_ISSUER="https://auth.gosec.cloud/realms/gosecCloud"
# Default tenant (optional)
DEFAULT_TENANT_ID="a0000000-0000-0000-0000-000000000003"
# Set to "true" to bypass authentication (for development)
SKIP_AUTH="false"
# Bicameral Chat API
BICAMERAL_API_URL="https://bicameral.gosec.cloud/api/v1"
NEXT_PUBLIC_BICAMERAL_WS_URL="wss://bicameral.gosec.cloud/ws"
# Ops API (personal agent config)
OPS_API_URL="https://172.17.8.20:8443"
OPS_API_KEY="your-ops-api-key"

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
.next/
.env
.env.local
.env.development.local
.env.production.local
.turbo/
limitless-ui
*.log
tsconfig.tsbuildinfo

78
Dockerfile Normal file
View File

@@ -0,0 +1,78 @@
# GoSec My UI — Production Dockerfile
#
# Build context MUST be the monorepo root (/srv/k8s):
# podman build -f web/gscMy/Dockerfile -t gsc-my-ui /srv/k8s
# Mirrors web/gscAdmin/Dockerfile.
# Stage 1: Builder.
FROM node:22-alpine AS builder
RUN apk add --no-cache libc6-compat openssl
# @limitless/ui — build dist/ first.
COPY templates/limitless-ui /srv/k8s/templates/limitless-ui
WORKDIR /srv/k8s/templates/limitless-ui
RUN npm install --no-audit --no-fund && npm run build
# @gsc/web-kit — build dist/, drop bundled next-intl/next-auth so they
# resolve from this app's tree (React-context unity).
COPY templates/gsc-web-kit /srv/k8s/templates/gsc-web-kit
WORKDIR /srv/k8s/templates/gsc-web-kit
RUN npm install --no-audit --no-fund && npm run build && \
rm -rf node_modules/next-intl node_modules/next-auth
# @gsc/chat — dist/ is gitignored; build in place.
COPY infra/gscAICoreSystem/frontends/gscBicameralFrontend /srv/k8s/infra/gscAICoreSystem/frontends/gscBicameralFrontend
WORKDIR /srv/k8s/infra/gscAICoreSystem/frontends/gscBicameralFrontend
RUN npm install --no-audit --no-fund && npm run build
# Make @gsc/chat resolvable from inside @gsc/web-kit.
RUN mkdir -p /srv/k8s/templates/gsc-web-kit/node_modules/@gsc && \
ln -sfn ../../../../infra/gscAICoreSystem/frontends/gscBicameralFrontend \
/srv/k8s/templates/gsc-web-kit/node_modules/@gsc/chat
# gscMy — package.json + prisma schema first so postinstall finds it.
WORKDIR /srv/k8s/web/gscMy
COPY web/gscMy/package.json web/gscMy/package-lock.json* ./
COPY web/gscMy/prisma ./prisma
RUN npm install --no-audit --no-fund --no-package-lock
# Link this app's next-intl + next-auth into the kit's node_modules
# so the kit's compiled chrome shares the same module instances.
RUN ln -sfn ../../../web/gscMy/node_modules/next-intl \
/srv/k8s/templates/gsc-web-kit/node_modules/next-intl && \
ln -sfn ../../../web/gscMy/node_modules/next-auth \
/srv/k8s/templates/gsc-web-kit/node_modules/next-auth
# Rest of source.
COPY web/gscMy/. ./
ARG NEXT_PUBLIC_APP_URL=https://my.gosec.internal
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
ENV NEXT_TELEMETRY_DISABLED=1
# Strip local-dev env so it doesn't bake into the image (esp. AUTH_URL
# pointing at my.gosec.cloud:3000).
RUN rm -f .env .env.local .env.development.local .env.production.local
RUN npm run build
# Stage 2: Runtime
FROM node:22-alpine AS runner
RUN apk add --no-cache openssl
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# outputFileTracingRoot=/srv/k8s preserves the web/gscMy subtree in the
# standalone bundle, so server.js lands at /app/web/gscMy/server.js.
COPY --from=builder --chown=node:node /srv/k8s/web/gscMy/.next/standalone ./
COPY --from=builder --chown=node:node /srv/k8s/web/gscMy/public ./web/gscMy/public
COPY --from=builder --chown=node:node /srv/k8s/web/gscMy/.next/static ./web/gscMy/.next/static
USER node
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
ENTRYPOINT ["node", "web/gscMy/server.js"]

223
k8s/deployment.yaml Normal file
View File

@@ -0,0 +1,223 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-ui
namespace: gsc-my
labels:
app: my-ui
component: frontend
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: my-ui
template:
metadata:
labels:
app: my-ui
component: frontend
# Egress to the in-cluster web-proxy (Squid) for NextAuth's
# Keycloak issuer-discovery fetch to auth.gosec.cloud.
egress-internet: "true"
# Egress to the internal-gateway proxy (Envoy) so the app can
# reach postgresql.internal-gateway.svc.cluster.local:5432.
# Selected by the allow-internal-gateway GlobalNetworkPolicy.
egress-internal: "true"
spec:
containers:
- name: my-ui
image: registry.gosec.internal/gsc-my/ui:v0.1.0
imagePullPolicy: Always
ports:
- containerPort: 3000
name: http
env:
- name: NODE_ENV
value: "production"
# Route Node fetch() through Squid for Keycloak discovery.
- name: HTTP_PROXY
value: "http://web-proxy.web-proxy.svc.cluster.local:3128"
- name: HTTPS_PROXY
value: "http://web-proxy.web-proxy.svc.cluster.local:3128"
- name: NO_PROXY
value: "localhost,127.0.0.1,.cluster.local,.svc,.gosec.internal"
- name: NEXT_PUBLIC_APP_URL
value: "https://my.gosec.internal"
# Postgres (gsc_core — admin/nav/public/shell schemas).
- name: PGHOST
valueFrom:
secretKeyRef:
name: gsc-my-db
key: host
- name: PGPORT
valueFrom:
secretKeyRef:
name: gsc-my-db
key: port
- name: PGUSER
valueFrom:
secretKeyRef:
name: gsc-my-db
key: user
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: gsc-my-db
key: password
- name: PGDATABASE
valueFrom:
secretKeyRef:
name: gsc-my-db
key: database
- name: DATABASE_URL
value: "postgresql://$(PGUSER):$(PGPASSWORD)@$(PGHOST):$(PGPORT)/$(PGDATABASE)"
# NextAuth + Keycloak (gosecCloud realm).
- name: AUTH_KEYCLOAK_ID
valueFrom:
secretKeyRef:
name: my-ui
key: keycloak-client-id
- name: AUTH_KEYCLOAK_SECRET
valueFrom:
secretKeyRef:
name: my-ui
key: keycloak-client-secret
- name: AUTH_KEYCLOAK_ISSUER
value: "https://auth.gosec.cloud/realms/gosecCloud"
- name: NEXTAUTH_URL
value: "https://my.gosec.internal"
# NextAuth v5 prefers AUTH_URL over NEXTAUTH_URL. Set both
# so a stale .env baked into a future image build can't
# shadow the runtime config and emit form actions pointing
# at the wrong host.
- name: AUTH_URL
value: "https://my.gosec.internal"
- name: AUTH_TRUST_HOST
value: "true"
- name: NEXTAUTH_SECRET
valueFrom:
secretKeyRef:
name: my-ui
key: nextauth-secret
# gsc-ops-api (mTLS) for chat contacts route. Cert files are
# mounted from a separate secret if/when the route is used;
# leaving the URL unset disables the contacts provider
# gracefully — see src/app/api/chat/contacts/route.ts.
# - name: OPS_API_URL
# - name: OPS_API_KEY
resources:
requests:
memory: "384Mi"
cpu: "100m"
limits:
memory: "768Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
- name: nextjs-cache
mountPath: /app/.next/cache
volumes:
- name: tmp
emptyDir: {}
- name: nextjs-cache
emptyDir: {}
imagePullSecrets:
- name: registry-credentials
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- my-ui
topologyKey: kubernetes.io/hostname
---
apiVersion: v1
kind: Service
metadata:
name: my-ui
namespace: gsc-my
labels:
app: my-ui
spec:
type: ClusterIP
ports:
- port: 3000
targetPort: 3000
protocol: TCP
name: http
selector:
app: my-ui
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-ui
namespace: gsc-my
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
# ModSecurity + OWASP CRS is enabled cluster-wide; the chunked
# NextAuth session cookie (>4 KB of base64 JWE) trips CRS rules
# and returns 403 before the request reaches the pod. Same fix
# applied to crm-ui / support-ui / chronos / gsc-meet / etc.
nginx.ingress.kubernetes.io/enable-modsecurity: "false"
nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"
nginx.ingress.kubernetes.io/proxy-buffers-number: "8"
# Hardening headers (CSP comes from cluster ConfigMap).
nginx.ingress.kubernetes.io/configuration-snippet: |
more_set_headers "X-Frame-Options: SAMEORIGIN";
more_set_headers "X-Content-Type-Options: nosniff";
more_set_headers "Referrer-Policy: strict-origin-when-cross-origin";
more_set_headers "Strict-Transport-Security: max-age=31536000; includeSubDomains";
spec:
ingressClassName: nginx
tls:
- hosts:
- my.gosec.internal
secretName: my-tls-internal
rules:
- host: my.gosec.internal
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-ui
port:
number: 3000

7
k8s/namespace.yaml Normal file
View File

@@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: gsc-my
labels:
app.kubernetes.io/name: gsc-my
istio.io/dataplane-mode: ambient

23
middleware.ts Normal file
View File

@@ -0,0 +1,23 @@
import { createAuthMiddleware } from "@gsc/web-kit/auth/middleware";
// Default: require auth for all routes. gscMy is the user-facing
// portal; only public surfaces are the auth entry point + the two
// branded standalone pages.
export default createAuthMiddleware({
signInPath: "/auth/keycloak",
publicRoutes: [
"/api/health",
"/access-denied",
"/auth/keycloak",
"/signed-out",
// PAM approval link — token in URL is the auth. Matcher below
// also excludes it so the kit's redirect logic doesn't fire.
"/api/pam/approve",
],
});
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|robots.txt|api/health|access-denied|auth/keycloak|signed-out|api/pam/approve).+)",
],
};

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

31
next.config.ts Normal file
View File

@@ -0,0 +1,31 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
import path from "path";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
allowedDevOrigins: ["my.gosec.cloud", "my.gosec.internal"],
transpilePackages: ["@gsc/web-kit", "@limitless/ui", "@gsc/chat"],
// Tracing root must point at the monorepo root (/srv/k8s) so the
// standalone bundle picks up the file: deps. From /srv/k8s/web/gscMy
// that's `../..` — `../../..` would resolve to /srv (one level
// too high).
outputFileTracingRoot: path.join(__dirname, "../.."),
turbopack: {
root: path.join(__dirname, "../.."),
},
typescript: {
// TODO: Fix type errors and remove this
ignoreBuildErrors: true,
},
experimental: {
serverActions: {
bodySizeLimit: "2mb",
},
},
};
export default withNextIntl(nextConfig);

7524
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "gsc-my",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"postinstall": "prisma generate"
},
"dependencies": {
"@gsc/chat": "file:../../infra/gscAICoreSystem/frontends/gscBicameralFrontend",
"@gsc/web-kit": "file:../../templates/gsc-web-kit",
"@limitless/ui": "file:../../templates/limitless-ui",
"@phosphor-icons/web": "2.1.1",
"@prisma/client": "^6.1.0",
"bootstrap": "^5.3.3",
"clsx": "^2.1.0",
"next": "^16.1.1",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.6.1",
"nodemailer": "^7.0.7",
"pg": "^8.20.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/pg": "^8.11.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.0.0",
"eslint-config-next": "^16.1.1",
"prisma": "^6.1.0",
"typescript": "^5.3.0"
},
"engines": {
"node": ">=18.0.0"
}
}

188
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,188 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [pgcrypto, uuid_ossp(map: "uuid-ossp", schema: "public")]
schemas = ["admin", "public"]
}
// ─── admin schema (shared with gscAdmin) ───────────────────────
model User {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
gscsid String @unique @db.VarChar(64)
firstName String? @map("first_name") @db.VarChar(128)
lastName String? @map("last_name") @db.VarChar(128)
displayName String? @map("display_name") @db.VarChar(256)
email String? @db.VarChar(256)
phone String? @db.VarChar(32)
mobile String? @db.VarChar(32)
avatarUrl String? @map("avatar_url") @db.VarChar(512)
timezone String? @default("UTC") @db.VarChar(64)
locale String? @default("en") @db.VarChar(10)
status String? @default("active") @db.VarChar(32)
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6)
lastActivityAt DateTime? @map("last_activity_at") @db.Timestamptz(6)
metadata Json? @default("{}")
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
settings UserSetting[]
@@index([gscsid], map: "idx_users_gscsid")
@@map("users")
@@schema("admin")
}
model SettingsDefinition {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
category String @db.VarChar(64)
key String @db.VarChar(128)
dataType String @map("data_type") @db.VarChar(32)
defaultValue Json? @map("default_value")
min_value Decimal? @db.Decimal
max_value Decimal? @db.Decimal
allowed_values Json?
allow_customer_override Boolean? @default(true)
allow_tenant_override Boolean? @default(true)
allow_user_override Boolean? @default(true)
description String?
display_order Int? @default(0)
customerSettings CustomerSetting[]
tenantSettings TenantSetting[]
userSettings UserSetting[]
@@unique([category, key])
@@map("settings_definitions")
@@schema("admin")
}
model CustomerSetting {
customerId String @map("customer_id") @db.Uuid
settingId String @map("setting_id") @db.Uuid
value Json
is_mandatory Boolean? @default(false)
allow_tenant_override Boolean? @default(true)
updated_by String? @db.Uuid
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
setting SettingsDefinition @relation(fields: [settingId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@id([customerId, settingId])
@@index([settingId, customerId], map: "idx_customer_settings_lookup")
@@map("customer_settings")
@@schema("admin")
}
model TenantSetting {
tenantId String @map("tenant_id") @db.Uuid
settingId String @map("setting_id") @db.Uuid
value Json
is_mandatory Boolean? @default(false)
allow_user_override Boolean? @default(true)
updated_by String? @db.Uuid
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
setting SettingsDefinition @relation(fields: [settingId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@id([tenantId, settingId])
@@index([settingId, tenantId], map: "idx_tenant_settings_lookup")
@@map("tenant_settings")
@@schema("admin")
}
model UserSetting {
userId String @map("user_id") @db.Uuid
settingId String @map("setting_id") @db.Uuid
value Json
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
setting SettingsDefinition @relation(fields: [settingId], references: [id], onDelete: Cascade, onUpdate: NoAction)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@id([userId, settingId])
@@map("user_settings")
@@schema("admin")
}
// ─── public schema ─────────────────────────────────────────────
model UserActivityLog {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
userId String @map("user_id") @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
action String @db.VarChar(64)
target String? @db.VarChar(256)
metadata Json? @default("{}")
ipAddress String? @map("ip_address") @db.VarChar(45)
userAgent String? @map("user_agent") @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@index([userId, createdAt(sort: Desc)], map: "idx_user_activity_user")
@@index([tenantId, createdAt(sort: Desc)], map: "idx_user_activity_tenant")
@@map("user_activity_log")
@@schema("public")
}
// ============================================================================
// Privileged Access Management (JIT elevation) — shared with gscAdmin.
// Tables created by gscAdmin's `20260518_pam_init` migration; gscMy
// only needs the model definitions to write/read them.
// See gscAdmin/docs/pam-plan.md for the full design.
// ============================================================================
model PrivilegePolicy {
roleName String @id @map("role_name") @db.VarChar(128)
maxDurationHours Int @default(4) @map("max_duration_hours") @db.SmallInt
defaultDurationHours Int @default(1) @map("default_duration_hours") @db.SmallInt
approvalMode String @default("audit") @map("approval_mode") @db.VarChar(16)
approverEmail String? @map("approver_email") @db.VarChar(256)
requiresMfa Boolean @default(false) @map("requires_mfa")
eligibleGroup String @map("eligible_group") @db.VarChar(128)
description String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
grants PrivilegeGrant[]
@@map("privilege_policies")
@@schema("admin")
}
model PrivilegeGrant {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
gscsid String @db.VarChar(64)
roleName String @map("role_name") @db.VarChar(128)
status String @default("pending") @db.VarChar(16)
approvalToken String? @unique @map("approval_token") @db.VarChar(64)
requestedAt DateTime @default(now()) @map("requested_at") @db.Timestamptz(6)
grantedAt DateTime? @map("granted_at") @db.Timestamptz(6)
expiresAt DateTime @map("expires_at") @db.Timestamptz(6)
grantedBy String? @map("granted_by") @db.VarChar(256)
justification String
mfaEvidence Json? @map("mfa_evidence")
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6)
revokedBy String? @map("revoked_by") @db.VarChar(64)
revokeReason String? @map("revoke_reason")
policy PrivilegePolicy @relation(fields: [roleName], references: [roleName], onUpdate: Cascade)
@@index([gscsid, roleName], map: "idx_priv_grants_active")
@@index([expiresAt], map: "idx_priv_grants_expires")
@@map("privilege_grants")
@@schema("admin")
}
model PrivilegeAudit {
id BigInt @id @default(autoincrement())
ts DateTime @default(now()) @db.Timestamptz(6)
event String @db.VarChar(32)
gscsid String @db.VarChar(64)
roleName String @map("role_name") @db.VarChar(128)
grantId String? @map("grant_id") @db.Uuid
actorGscsid String? @map("actor_gscsid") @db.VarChar(64)
actorEmail String? @map("actor_email") @db.VarChar(256)
detail Json?
@@index([gscsid, ts(sort: Desc)], map: "idx_priv_audit_user_ts")
@@map("privilege_audit")
@@schema("admin")
}

View File

@@ -0,0 +1,23 @@
{
"header": {
"apps": {},
"messages": {},
"search": {},
"notification": {},
"top-menu": {},
"sub-bar": {
"support": "Support",
"settings": "Einstellungen"
}
},
"menu": {
"support": "Support",
"settings": "Einstellungen",
"allSettings": "Alle Einstellungen",
"logout": "Abmelden"
},
"sidebar": {},
"footer": {
"docs": "Dokumentation"
}
}

View File

@@ -0,0 +1,23 @@
{
"header": {
"apps": {},
"messages": {},
"search": {},
"notification": {},
"top-menu": {},
"sub-bar": {
"support": "Support",
"settings": "Settings"
}
},
"menu": {
"support": "Support",
"settings": "Settings",
"allSettings": "All settings",
"logout": "Logout"
},
"sidebar": {},
"footer": {
"docs": "Docs"
}
}

View File

@@ -0,0 +1,23 @@
{
"header": {
"apps": {},
"messages": {},
"search": {},
"notification": {},
"top-menu": {},
"sub-bar": {
"support": "Support",
"settings": "Paramètres"
}
},
"menu": {
"support": "Support",
"settings": "Paramètres",
"allSettings": "Tous les paramètres",
"logout": "Déconnexion"
},
"sidebar": {},
"footer": {
"docs": "Documentation"
}
}

111
scripts/seed-settings.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* Seed settings definitions into gsc_core.admin.settings_definitions.
* Upserts on (category, key) unique constraint.
*
* Usage: npx tsx scripts/seed-settings.ts
*/
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
interface SettingDef {
category: string;
key: string;
dataType: string;
defaultValue: unknown;
description: string;
allowedValues?: unknown;
displayOrder: number;
}
const SETTINGS: SettingDef[] = [
// ─── user.general ──────────────────────────────────────────
{ category: "user.general", key: "language", dataType: "string", defaultValue: "en", description: "Display language", allowedValues: ["en", "de", "fr"], displayOrder: 1 },
{ category: "user.general", key: "timezone", dataType: "string", defaultValue: "Europe/Helsinki", description: "User timezone for timestamps and scheduling", displayOrder: 2 },
{ category: "user.general", key: "dateFormat", dataType: "string", defaultValue: "DD/MM/YYYY", description: "Date display format", allowedValues: ["DD/MM/YYYY", "MM/DD/YYYY", "YYYY-MM-DD"], displayOrder: 3 },
{ category: "user.general", key: "theme", dataType: "string", defaultValue: "light", description: "UI theme", allowedValues: ["light", "dark", "system"], displayOrder: 4 },
{ category: "user.general", key: "compactMode", dataType: "boolean", defaultValue: false, description: "Reduce spacing for condensed layout", displayOrder: 5 },
{ category: "user.general", key: "showWelcomeScreen", dataType: "boolean", defaultValue: true, description: "Show welcome greeting on dashboard", displayOrder: 6 },
{ category: "user.general", key: "defaultLandingPage", dataType: "string", defaultValue: "/", description: "Default page after login", allowedValues: ["/", "/marketplace", "/account/profile"], displayOrder: 7 },
// ─── user.notifications ────────────────────────────────────
{ category: "user.notifications", key: "notifyEmail", dataType: "boolean", defaultValue: true, description: "Receive important updates via email", displayOrder: 1 },
{ category: "user.notifications", key: "notifyBrowser", dataType: "boolean", defaultValue: true, description: "Show desktop push notifications", displayOrder: 2 },
{ category: "user.notifications", key: "notifyActivity", dataType: "boolean", defaultValue: true, description: "Get notified about mentions, comments, shares", displayOrder: 3 },
{ category: "user.notifications", key: "notifySecurityAlerts", dataType: "boolean", defaultValue: true, description: "Notify about suspicious activity", displayOrder: 4 },
{ category: "user.notifications", key: "notifyProductUpdates", dataType: "boolean", defaultValue: false, description: "Get notified about new features", displayOrder: 5 },
// ─── user.email ────────────────────────────────────────────
{ category: "user.email", key: "messagesPerPage", dataType: "string", defaultValue: "50", description: "Messages per page in email client", allowedValues: ["25", "50", "100"], displayOrder: 1 },
{ category: "user.email", key: "defaultReplyMode", dataType: "string", defaultValue: "reply", description: "Default reply mode", allowedValues: ["reply", "replyAll"], displayOrder: 2 },
{ category: "user.email", key: "emailSignature", dataType: "string", defaultValue: "", description: "Email signature text", displayOrder: 3 },
{ category: "user.email", key: "composeFormat", dataType: "string", defaultValue: "html", description: "Email compose format", allowedValues: ["html", "plain"], displayOrder: 4 },
{ category: "user.email", key: "readReceipts", dataType: "boolean", defaultValue: true, description: "Request read receipts by default", displayOrder: 5 },
// ─── user.calendar ─────────────────────────────────────────
{ category: "user.calendar", key: "calendarDefaultView", dataType: "string", defaultValue: "week", description: "Default calendar view", allowedValues: ["month", "week", "day", "agenda"], displayOrder: 1 },
{ category: "user.calendar", key: "weekStartsOn", dataType: "string", defaultValue: "monday", description: "First day of week", allowedValues: ["monday", "sunday", "saturday"], displayOrder: 2 },
{ category: "user.calendar", key: "workingHoursStart", dataType: "string", defaultValue: "08:00", description: "Working hours start time", displayOrder: 3 },
{ category: "user.calendar", key: "workingHoursEnd", dataType: "string", defaultValue: "17:00", description: "Working hours end time", displayOrder: 4 },
{ category: "user.calendar", key: "defaultReminder", dataType: "string", defaultValue: "15", description: "Default reminder (minutes before)", allowedValues: ["5", "10", "15", "30", "60"], displayOrder: 5 },
{ category: "user.calendar", key: "defaultEventDuration", dataType: "string", defaultValue: "60", description: "Default event duration (minutes)", allowedValues: ["15", "30", "60", "90"], displayOrder: 6 },
// ─── user.privacy ──────────────────────────────────────────
{ category: "user.privacy", key: "profileVisibility", dataType: "string", defaultValue: "contacts", description: "Who can see your profile", allowedValues: ["public", "contacts", "private"], displayOrder: 1 },
{ category: "user.privacy", key: "showEmail", dataType: "boolean", defaultValue: false, description: "Show email on profile", displayOrder: 2 },
{ category: "user.privacy", key: "showPhone", dataType: "boolean", defaultValue: false, description: "Show phone on profile", displayOrder: 3 },
{ category: "user.privacy", key: "showLocation", dataType: "boolean", defaultValue: true, description: "Show location on profile", displayOrder: 4 },
{ category: "user.privacy", key: "showOnlineStatus", dataType: "boolean", defaultValue: true, description: "Show online status", displayOrder: 5 },
{ category: "user.privacy", key: "allowDirectMessages", dataType: "boolean", defaultValue: true, description: "Allow direct messages", displayOrder: 6 },
{ category: "user.privacy", key: "allowGroupInvites", dataType: "boolean", defaultValue: true, description: "Allow group invites", displayOrder: 7 },
{ category: "user.privacy", key: "allowMentions", dataType: "boolean", defaultValue: true, description: "Allow @mentions", displayOrder: 8 },
{ category: "user.privacy", key: "allowAnalytics", dataType: "boolean", defaultValue: true, description: "Share anonymous usage data", displayOrder: 9 },
{ category: "user.privacy", key: "allowPersonalization", dataType: "boolean", defaultValue: true, description: "Allow personalized recommendations", displayOrder: 10 },
{ category: "user.privacy", key: "allowThirdPartyIntegrations", dataType: "boolean", defaultValue: false, description: "Allow connected apps to access data", displayOrder: 11 },
{ category: "user.privacy", key: "emailMarketing", dataType: "boolean", defaultValue: false, description: "Receive marketing emails", displayOrder: 12 },
{ category: "user.privacy", key: "emailProductUpdates", dataType: "boolean", defaultValue: true, description: "Receive product update emails", displayOrder: 13 },
{ category: "user.privacy", key: "emailSecurityAlerts", dataType: "boolean", defaultValue: true, description: "Receive security alert emails", displayOrder: 14 },
{ category: "user.privacy", key: "emailActivityDigest", dataType: "boolean", defaultValue: true, description: "Receive weekly activity digest", displayOrder: 15 },
{ category: "user.privacy", key: "activityHistoryRetention", dataType: "string", defaultValue: "90d", description: "Activity history retention period", allowedValues: ["30d", "90d", "1y", "forever"], displayOrder: 16 },
{ category: "user.privacy", key: "searchHistoryEnabled", dataType: "boolean", defaultValue: true, description: "Save search history", displayOrder: 17 },
{ category: "user.privacy", key: "sessionTimeout", dataType: "number", defaultValue: 60, description: "Session timeout in minutes", displayOrder: 18 },
// ─── user.security ─────────────────────────────────────────
{ category: "user.security", key: "loginNotifications", dataType: "boolean", defaultValue: true, description: "Notify on new device logins", displayOrder: 1 },
];
async function main() {
console.log(`Seeding ${SETTINGS.length} settings definitions...`);
for (const s of SETTINGS) {
await prisma.settingsDefinition.upsert({
where: { category_key: { category: s.category, key: s.key } },
update: {
dataType: s.dataType,
defaultValue: s.defaultValue as never,
description: s.description,
allowed_values: s.allowedValues ? (s.allowedValues as never) : undefined,
display_order: s.displayOrder,
},
create: {
category: s.category,
key: s.key,
dataType: s.dataType,
defaultValue: s.defaultValue as never,
description: s.description,
allowed_values: s.allowedValues ? (s.allowedValues as never) : undefined,
display_order: s.displayOrder,
},
});
}
console.log("Done.");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

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 || ""}
/>
);
}

View File

@@ -0,0 +1,29 @@
import Link from "next/link";
export const metadata = { title: "Access Denied — GSC My" };
// NextAuth's error landing — typically only hit if Keycloak auth
// itself failed (misconfigured client / cancelled login). gscMy
// has no role-based signin gate so this should be rare.
export default function AccessDeniedPage() {
return (
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-light">
<div className="card shadow-sm" style={{ maxWidth: 480 }}>
<div className="card-body p-4 text-center">
<div className="mb-3">
<i className="ph ph-shield-warning text-danger" style={{ fontSize: "3rem" }}></i>
</div>
<h4 className="mb-2">Access Denied</h4>
<p className="text-muted mb-4">
We couldn&apos;t complete your sign-in. Please try again or
contact support if the problem persists.
</p>
<div className="d-flex gap-2 justify-content-center">
<Link href="/auth/keycloak" className="btn btn-primary">Try again</Link>
<a href="https://support.gosec.cloud/" className="btn btn-outline-secondary">Contact Support</a>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAuth } from "@/auth";
import { batchUpsertUserSettings } from "@/database/settings";
export interface ActionResult {
success?: boolean;
message?: string;
error?: string;
}
const PRIVACY_KEYS = [
"profileVisibility", "showEmail", "showPhone", "showLocation", "showOnlineStatus",
"allowDirectMessages", "allowGroupInvites", "allowMentions",
"allowAnalytics", "allowPersonalization", "allowThirdPartyIntegrations",
"emailMarketing", "emailProductUpdates", "emailSecurityAlerts", "emailActivityDigest",
"activityHistoryRetention", "searchHistoryEnabled", "sessionTimeout",
];
const BOOLEAN_KEYS = new Set([
"showEmail", "showPhone", "showLocation", "showOnlineStatus",
"allowDirectMessages", "allowGroupInvites", "allowMentions",
"allowAnalytics", "allowPersonalization", "allowThirdPartyIntegrations",
"emailMarketing", "emailProductUpdates", "emailSecurityAlerts", "emailActivityDigest",
"searchHistoryEnabled",
]);
export async function savePrivacySettings(data: Record<string, unknown>): Promise<ActionResult> {
try {
const user = await requireAuth();
if (!user.gscUserId) {
return { error: "User not linked to database. Please re-login." };
}
const settings = PRIVACY_KEYS
.filter((key) => data[key] !== undefined)
.map((key) => ({
category: "user.privacy",
key,
value: BOOLEAN_KEYS.has(key)
? Boolean(data[key])
: key === "sessionTimeout"
? Number(data[key])
: data[key],
}));
await batchUpsertUserSettings(user.gscUserId, settings);
revalidatePath("/privacy");
return { success: true, message: "Privacy settings saved successfully." };
} catch (error) {
console.error("[privacy] Save error:", error);
return { error: "Failed to save privacy settings." };
}
}

View File

@@ -0,0 +1,30 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAuth } from "@/auth";
import { batchUpsertUserSettings } from "@/database/settings";
export interface ActionResult {
success?: boolean;
message?: string;
error?: string;
}
export async function saveSecuritySettings(data: { loginNotifications: boolean }): Promise<ActionResult> {
try {
const user = await requireAuth();
if (!user.gscUserId) {
return { error: "User not linked to database. Please re-login." };
}
await batchUpsertUserSettings(user.gscUserId, [
{ category: "user.security", key: "loginNotifications", value: data.loginNotifications },
]);
revalidatePath("/security");
return { success: true, message: "Security settings updated successfully." };
} catch (error) {
console.error("[security] Save error:", error);
return { error: "Failed to save security settings." };
}
}

View File

@@ -0,0 +1,67 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAuth } from "@/auth";
import { batchUpsertUserSettings } from "@/database/settings";
export interface ActionResult {
success?: boolean;
message?: string;
error?: string;
}
const ACCOUNT_SETTINGS_KEYS = [
// General
"language", "timezone", "dateFormat", "theme", "compactMode", "showWelcomeScreen", "defaultLandingPage",
// Notifications
"notifyEmail", "notifyBrowser", "notifyActivity", "notifySecurityAlerts", "notifyProductUpdates",
// Email
"messagesPerPage", "defaultReplyMode", "emailSignature", "composeFormat", "readReceipts",
// Calendar
"calendarDefaultView", "weekStartsOn", "workingHoursStart", "workingHoursEnd", "defaultReminder", "defaultEventDuration",
];
const CATEGORY_MAP: Record<string, string> = {
language: "user.general", timezone: "user.general", dateFormat: "user.general",
theme: "user.general", compactMode: "user.general", showWelcomeScreen: "user.general",
defaultLandingPage: "user.general",
notifyEmail: "user.notifications", notifyBrowser: "user.notifications",
notifyActivity: "user.notifications", notifySecurityAlerts: "user.notifications",
notifyProductUpdates: "user.notifications",
messagesPerPage: "user.email", defaultReplyMode: "user.email",
emailSignature: "user.email", composeFormat: "user.email", readReceipts: "user.email",
calendarDefaultView: "user.calendar", weekStartsOn: "user.calendar",
workingHoursStart: "user.calendar", workingHoursEnd: "user.calendar",
defaultReminder: "user.calendar", defaultEventDuration: "user.calendar",
};
const BOOLEAN_KEYS = new Set([
"compactMode", "showWelcomeScreen",
"notifyEmail", "notifyBrowser", "notifyActivity", "notifySecurityAlerts", "notifyProductUpdates",
"readReceipts",
]);
export async function saveAccountSettings(data: Record<string, unknown>): Promise<ActionResult> {
try {
const user = await requireAuth();
if (!user.gscUserId) {
return { error: "User not linked to database. Please re-login." };
}
const settings = ACCOUNT_SETTINGS_KEYS
.filter((key) => data[key] !== undefined)
.map((key) => ({
category: CATEGORY_MAP[key],
key,
value: BOOLEAN_KEYS.has(key) ? Boolean(data[key]) : data[key],
}));
await batchUpsertUserSettings(user.gscUserId, settings);
revalidatePath("/settings");
return { success: true, message: "Settings saved successfully." };
} catch (error) {
console.error("[settings] Save error:", error);
return { error: "Failed to save settings." };
}
}

103
src/app/actions/voice.ts Normal file
View File

@@ -0,0 +1,103 @@
"use server";
import { revalidatePath } from "next/cache";
import {
upsertFollowMe,
updateExtension,
updateVoicemailBox,
} from "@/database/pbx";
export interface ActionResult {
success?: boolean;
message?: string;
error?: string;
}
export async function updateUserCallForwarding(formData: FormData): Promise<ActionResult> {
const extensionId = formData.get("extensionId") as string;
const forwardingEnabled = formData.has("forwardingEnabled");
const ringTime = formData.get("ringTime") as string;
if (!extensionId) {
return { error: "Extension ID is required" };
}
try {
const numbers: Array<{ number: string; ringDelay: number; orderNum: number }> = [];
let idx = 0;
while (formData.has(`forwardNumber_${idx}`)) {
const number = formData.get(`forwardNumber_${idx}`) as string;
if (number && number.trim()) {
numbers.push({ number: number.trim(), ringDelay: 0, orderNum: idx + 1 });
}
idx++;
}
await upsertFollowMe(extensionId, {
isEnabled: forwardingEnabled,
ringTime: ringTime ? parseInt(ringTime) : 20,
numbers,
});
revalidatePath("/voice");
return { success: true, message: "Call forwarding settings updated" };
} catch (error) {
console.error("Error updating call forwarding:", error);
return { error: "Failed to update call forwarding settings" };
}
}
export async function updateUserMoh(formData: FormData): Promise<ActionResult> {
const tenantId = formData.get("tenantId") as string;
const extensionId = formData.get("extensionId") as string;
const mohClassId = formData.get("mohClassId") as string;
if (!tenantId || !extensionId) {
return { error: "Tenant ID and Extension ID are required" };
}
try {
await updateExtension(tenantId, extensionId, {
mohClassId: mohClassId || null,
});
revalidatePath("/voice");
return { success: true, message: "Music on Hold settings updated" };
} catch (error) {
console.error("Error updating MOH:", error);
return { error: "Failed to update Music on Hold settings" };
}
}
export async function updateUserVoicemail(formData: FormData): Promise<ActionResult> {
const tenantId = formData.get("tenantId") as string;
const voicemailBoxId = formData.get("voicemailBoxId") as string;
const email = formData.get("email") as string;
const pin = formData.get("pin") as string;
const emailNotify = formData.has("emailNotify");
const attachVoicemail = formData.has("attachVoicemail");
if (!tenantId || !voicemailBoxId) {
return { error: "Tenant ID and Voicemail Box ID are required" };
}
try {
const data: Record<string, unknown> = {
email: email || null,
emailNotify,
attachVoicemail,
};
if (pin && pin.trim()) {
data.pin = pin.trim();
}
await updateVoicemailBox(tenantId, voicemailBoxId, data as Parameters<typeof updateVoicemailBox>[2]);
revalidatePath("/voice");
return { success: true, message: "Voicemail settings updated" };
} catch (error) {
console.error("Error updating voicemail:", error);
return { error: "Failed to update voicemail settings" };
}
}

View File

@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from "next/server";
import https from "node:https";
import fs from "node:fs";
import { requireAuth } from "@/auth";
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 opsApiFetch(
path: string,
method: string = "GET",
body?: string
): Promise<{ status: number; data: 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,
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", () =>
resolve({
status: res.statusCode ?? 500,
data: Buffer.concat(chunks).toString(),
})
);
}
);
req.on("error", reject);
if (body) req.write(body);
req.end();
});
}
export async function GET() {
try {
const user = await requireAuth();
const userId = user.gscUserId;
const tenantId = user.tenantId;
if (!userId || !tenantId) {
return NextResponse.json(null);
}
const res = await opsApiFetch(
`/api/v1/agents/me?userId=${userId}&tenantId=${tenantId}`
);
if (res.status >= 400) {
return NextResponse.json(null);
}
const envelope = JSON.parse(res.data);
return NextResponse.json(envelope.data?.config ?? null);
} catch {
return NextResponse.json(null, { status: 401 });
}
}
export async function PUT(req: NextRequest) {
try {
const user = await requireAuth();
const userId = user.gscUserId;
const tenantId = user.tenantId;
if (!userId || !tenantId) {
return NextResponse.json(
{ error: "Missing user identity" },
{ status: 400 }
);
}
const body = await req.json();
const res = await opsApiFetch(
"/api/v1/agents/me",
"PUT",
JSON.stringify({ userId, tenantId, config: body })
);
if (res.status >= 400) {
return NextResponse.json({ error: res.data }, { status: res.status });
}
const envelope = JSON.parse(res.data);
return NextResponse.json(envelope.data?.config ?? null);
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function DELETE() {
try {
const user = await requireAuth();
const userId = user.gscUserId;
const tenantId = user.tenantId;
if (!userId || !tenantId) {
return NextResponse.json(
{ error: "Missing user identity" },
{ status: 400 }
);
}
await opsApiFetch(
`/api/v1/agents/me?userId=${userId}&tenantId=${tenantId}`,
"DELETE"
);
return new NextResponse(null, { status: 204 });
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import type { SessionUser } from "@/auth";
export async function GET() {
const session = await auth();
const user = session?.user as unknown as SessionUser | undefined;
const idToken = user?.idToken;
const issuer = process.env.AUTH_KEYCLOAK_ISSUER;
const clientId = process.env.AUTH_KEYCLOAK_ID || "";
const baseUrl = process.env.AUTH_URL || "http://localhost:3007";
if (!issuer) {
return NextResponse.json({ logoutUrl: `${baseUrl}/logged-out` });
}
const logoutUrl = new URL(`${issuer}/protocol/openid-connect/logout`);
logoutUrl.searchParams.set("post_logout_redirect_uri", `${baseUrl}/logged-out`);
logoutUrl.searchParams.set("client_id", clientId);
if (idToken) {
logoutUrl.searchParams.set("id_token_hint", idToken);
}
return NextResponse.json({ logoutUrl: logoutUrl.toString() });
}

View File

@@ -0,0 +1,31 @@
import { auth, signOut } from "@/auth";
// RP-initiated logout. Overrides NextAuth's default /api/auth/signout
// confirmation page: kills both the NextAuth cookie and the Keycloak
// SSO session, then bounces to /signed-out.
async function handleSignout(request: Request): Promise<Response> {
const session = await auth();
const idToken = (session?.user as { idToken?: string } | undefined)?.idToken;
await signOut({ redirect: false });
const issuer = process.env.AUTH_KEYCLOAK_ISSUER;
const origin = (
process.env.AUTH_URL ??
process.env.NEXTAUTH_URL ??
new URL(request.url).origin
).replace(/\/$/, "");
const postLogout = `${origin}/signed-out`;
if (!issuer) return Response.redirect(postLogout, 302);
const endSession = new URL(`${issuer}/protocol/openid-connect/logout`);
endSession.searchParams.set("post_logout_redirect_uri", postLogout);
if (idToken) endSession.searchParams.set("id_token_hint", idToken);
else endSession.searchParams.set("client_id", process.env.AUTH_KEYCLOAK_ID ?? "");
return Response.redirect(endSession.toString(), 302);
}
export const GET = handleSignout;
export const POST = handleSignout;

View File

@@ -0,0 +1,5 @@
export const dynamic = "force-dynamic";
export async function GET() {
return Response.json({ status: "healthy", service: "gsc-my" });
}

View File

@@ -0,0 +1,14 @@
// GET /api/pam/active — current user's active grants
import { auth } from "@/auth";
import { listActiveGrants } from "@/lib/pam";
export const dynamic = "force-dynamic";
export async function GET() {
const session = await auth();
const user = session?.user as { id?: string } | undefined;
if (!user?.id) return Response.json({ error: "unauthorized" }, { status: 401 });
const grants = await listActiveGrants(user.id);
return Response.json({ active: grants });
}

View File

@@ -0,0 +1,151 @@
// GET /api/pam/approve/[token]
//
// Approval link clicked by the configured approver. Validates the
// token, activates the grant, audits the click. Tokens expire 24 h
// after the grant was requested; expired tokens deny the grant.
//
// This endpoint is intentionally NOT behind the auth middleware
// (handled by the matcher in middleware.ts) — the token IS the
// auth. Token is 24 bytes of base64url randomness.
import { prisma } from "@/database/prisma";
import { invalidateAuthzCache } from "@/lib/authz";
import { recordAudit } from "@/lib/pam";
export const dynamic = "force-dynamic";
const APPROVAL_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
function htmlPage(title: string, body: string, accent = "#0066cc"): Response {
const html = `<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<title>${title} — GSC Admin PAM</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 6rem auto; padding: 0 1rem; color: #222; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 2rem; box-shadow: 0 2px 8px rgba(0,0,0,.05); }
h1 { color: ${accent}; margin-top: 0; }
code { background: #f4f4f4; padding: 1px 4px; border-radius: 3px; }
</style>
</head><body>
<div class="card">
<h1>${title}</h1>
${body}
</div>
</body></html>`;
return new Response(html, {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
export async function GET(_req: Request, ctx: { params: Promise<{ token: string }> }) {
const { token } = await ctx.params;
if (!token || token.length < 16) {
return htmlPage("Invalid link", "<p>This approval link is malformed.</p>", "#cc0000");
}
const grant = await prisma.privilegeGrant.findUnique({
where: { approvalToken: token },
select: {
id: true,
status: true,
gscsid: true,
roleName: true,
requestedAt: true,
expiresAt: true,
justification: true,
policy: { select: { approverEmail: true } },
},
});
if (!grant) {
return htmlPage(
"Link not found",
"<p>No matching pending request — it may have been revoked or already approved.</p>",
"#cc0000",
);
}
if (grant.status === "active") {
return htmlPage(
"Already approved",
`<p>The request for <code>${escapeHtml(grant.roleName)}</code> is already active.</p>`,
);
}
if (grant.status !== "pending") {
return htmlPage(
"Cannot approve",
`<p>This request is in state <code>${escapeHtml(grant.status)}</code> and can't be approved.</p>`,
"#cc0000",
);
}
const ageMs = Date.now() - grant.requestedAt.getTime();
if (ageMs > APPROVAL_TOKEN_TTL_MS) {
await prisma.privilegeGrant.update({
where: { id: grant.id },
data: { status: "denied" },
});
await recordAudit({
event: "denied",
gscsid: grant.gscsid,
roleName: grant.roleName,
grantId: grant.id,
detail: { reason: "approval_token_expired" },
});
return htmlPage(
"Link expired",
`<p>This approval link expired (older than 24 h). The request has been denied; the user can submit a new request.</p>`,
"#cc0000",
);
}
// Recompute expires_at relative to NOW (the duration stays the same).
const durationMs = grant.expiresAt.getTime() - grant.requestedAt.getTime();
const newExpires = new Date(Date.now() + durationMs);
await prisma.privilegeGrant.update({
where: { id: grant.id },
data: {
status: "active",
grantedAt: new Date(),
expiresAt: newExpires,
approvalToken: null, // burn the token
grantedBy: grant.policy?.approverEmail ?? "approver",
},
});
await recordAudit({
event: "approved-manual",
gscsid: grant.gscsid,
roleName: grant.roleName,
grantId: grant.id,
actorEmail: grant.policy?.approverEmail ?? null,
});
await recordAudit({
event: "activated",
gscsid: grant.gscsid,
roleName: grant.roleName,
grantId: grant.id,
actorEmail: grant.policy?.approverEmail ?? null,
});
invalidateAuthzCache(grant.gscsid, grant.roleName);
return htmlPage(
"Approved",
`<p>Granted <code>${escapeHtml(grant.roleName)}</code> until <strong>${newExpires.toISOString()}</strong>.</p>
<p>The requesting user can now use this privilege without re-logging in.</p>`,
"#0a8a3e",
);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

View File

@@ -0,0 +1,14 @@
// GET /api/pam/audit — current user's recent privilege audit entries
import { auth } from "@/auth";
import { listAudit } from "@/lib/pam";
export const dynamic = "force-dynamic";
export async function GET() {
const session = await auth();
const user = session?.user as { id?: string } | undefined;
if (!user?.id) return Response.json({ error: "unauthorized" }, { status: 401 });
const rows = await listAudit(user.id, 100);
return Response.json({ audit: rows });
}

View File

@@ -0,0 +1,64 @@
// GET /api/pam/eligible
// Returns the roles the user can JIT-elevate to (from JWT *_eligible
// realm roles), enriched with policy config + whether a grant is
// already active.
import { auth } from "@/auth";
import { eligibleRoles } from "@/lib/authz";
import { prisma } from "@/database/prisma";
export const dynamic = "force-dynamic";
export async function GET() {
const session = await auth();
const user = session?.user as { id?: string; roles?: string[] } | undefined;
if (!user?.id) return Response.json({ error: "unauthorized" }, { status: 401 });
const targetRoles = eligibleRoles(user as { roles: string[] });
if (targetRoles.length === 0) return Response.json({ eligible: [] });
const [policies, activeGrants] = await Promise.all([
prisma.privilegePolicy.findMany({
where: { roleName: { in: targetRoles } },
select: {
roleName: true,
maxDurationHours: true,
defaultDurationHours: true,
approvalMode: true,
requiresMfa: true,
description: true,
},
}),
prisma.privilegeGrant.findMany({
where: {
gscsid: user.id,
roleName: { in: targetRoles },
status: "active",
expiresAt: { gt: new Date() },
},
select: { roleName: true },
}),
]);
const policyByRole = new Map(policies.map((p) => [p.roleName, p]));
const activeRoles = new Set(activeGrants.map((g) => g.roleName));
return Response.json({
eligible: targetRoles.map((role) => {
const policy = policyByRole.get(role);
return {
role,
policy: policy
? {
approvalMode: policy.approvalMode,
requiresMfa: policy.requiresMfa,
maxDurationHours: policy.maxDurationHours,
defaultDurationHours: policy.defaultDurationHours,
description: policy.description,
}
: null, // role is eligible in IPA but no policy yet
currentlyActive: activeRoles.has(role),
};
}),
});
}

View File

@@ -0,0 +1,208 @@
// POST /api/pam/request
//
// Body: { role, durationHours, justification, mfaToken? }
//
// Validates eligibility (the requester's JWT must carry `<role>_eligible`),
// checks the policy, optionally verifies MFA, then either:
// - `audit` mode: activates the grant immediately + audit-logs.
// - `manual` mode: writes status='pending' + token, sends approval email.
import { auth } from "@/auth";
import { z } from "zod";
import { prisma } from "@/database/prisma";
import {
eligibleRoles,
invalidateAuthzCache,
} from "@/lib/authz";
import {
generateApprovalToken,
getPolicy,
recordAudit,
} from "@/lib/pam";
import { isMailConfigured, sendApprovalEmail } from "@/lib/pam-mail";
import { verifyMfa } from "@/lib/pam-mfa";
export const dynamic = "force-dynamic";
const RequestBody = z.object({
role: z.string().min(1).max(128),
durationHours: z.number().int().positive().max(24),
justification: z.string().min(20).max(1000),
mfaToken: z.string().optional(),
});
function publicOrigin(req: Request): string {
return (
process.env.AUTH_URL ??
process.env.NEXTAUTH_URL ??
new URL(req.url).origin
).replace(/\/$/, "");
}
export async function POST(req: Request) {
const session = await auth();
const user = session?.user as
| { id?: string; email?: string; displayName?: string; roles?: string[] }
| undefined;
if (!user?.id) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
let body: z.infer<typeof RequestBody>;
try {
body = RequestBody.parse(await req.json());
} catch (err) {
return Response.json({ error: "bad_request", detail: String(err) }, { status: 400 });
}
// Eligibility — the *_eligible realm role must be in the JWT.
if (!eligibleRoles({ roles: user.roles ?? [] }).includes(body.role)) {
await recordAudit({
event: "denied",
gscsid: user.id,
roleName: body.role,
detail: { reason: "not_eligible" },
});
return Response.json({ error: "not_eligible" }, { status: 403 });
}
const policy = await getPolicy(body.role);
if (!policy) {
await recordAudit({
event: "denied",
gscsid: user.id,
roleName: body.role,
detail: { reason: "no_policy" },
});
return Response.json({ error: "no_policy_for_role" }, { status: 404 });
}
if (body.durationHours > policy.maxDurationHours) {
return Response.json(
{
error: "duration_exceeds_max",
maxDurationHours: policy.maxDurationHours,
},
{ status: 400 },
);
}
// MFA check if policy requires it.
let mfaEvidence: Record<string, unknown> | undefined;
if (policy.requiresMfa) {
const v = await verifyMfa({ gscsid: user.id, token: body.mfaToken ?? "" });
if (!v.ok) {
await recordAudit({
event: "denied",
gscsid: user.id,
roleName: body.role,
detail: { reason: "mfa_failed", mfa_reason: v.reason },
});
return Response.json({ error: "mfa_failed", detail: v.reason }, { status: 401 });
}
mfaEvidence = v.evidence;
}
const expiresAt = new Date(Date.now() + body.durationHours * 3600 * 1000);
// Branch by approval mode.
if (policy.approvalMode === "audit") {
// Auto-approve + activate.
const grant = await prisma.privilegeGrant.create({
data: {
gscsid: user.id,
roleName: body.role,
status: "active",
requestedAt: new Date(),
grantedAt: new Date(),
expiresAt,
grantedBy: null, // self-service
justification: body.justification,
mfaEvidence: (mfaEvidence as never) ?? undefined,
},
select: { id: true, expiresAt: true, roleName: true },
});
await recordAudit({
event: "requested",
gscsid: user.id,
roleName: body.role,
grantId: grant.id,
detail: { durationHours: body.durationHours, justification: body.justification },
});
await recordAudit({
event: "approved-auto",
gscsid: user.id,
roleName: body.role,
grantId: grant.id,
});
await recordAudit({
event: "activated",
gscsid: user.id,
roleName: body.role,
grantId: grant.id,
});
invalidateAuthzCache(user.id, body.role);
return Response.json({
status: "active",
grant: { id: grant.id, role: grant.roleName, expiresAt: grant.expiresAt },
});
}
// manual mode — need SMTP + approver_email
if (!policy.approverEmail) {
return Response.json({ error: "policy_misconfigured_no_approver" }, { status: 500 });
}
if (!isMailConfigured()) {
return Response.json(
{ error: "mail_not_configured", detail: "PAM_SMTP_HOST is not set; manual-mode requests can't be sent." },
{ status: 503 },
);
}
const token = generateApprovalToken();
const grant = await prisma.privilegeGrant.create({
data: {
gscsid: user.id,
roleName: body.role,
status: "pending",
approvalToken: token,
requestedAt: new Date(),
expiresAt,
justification: body.justification,
mfaEvidence: (mfaEvidence as never) ?? undefined,
},
select: { id: true, roleName: true },
});
await recordAudit({
event: "requested",
gscsid: user.id,
roleName: body.role,
grantId: grant.id,
detail: { durationHours: body.durationHours, justification: body.justification },
});
const approveUrl = `${publicOrigin(req)}/api/pam/approve/${encodeURIComponent(token)}`;
try {
await sendApprovalEmail({
to: policy.approverEmail,
approveUrl,
requesterDisplay: user.displayName
? `${user.displayName} (${user.email ?? user.id})`
: (user.email ?? user.id),
roleName: body.role,
durationHours: body.durationHours,
justification: body.justification,
});
} catch (err) {
return Response.json(
{ error: "mail_send_failed", detail: String(err) },
{ status: 502 },
);
}
return Response.json({
status: "pending",
grant: { id: grant.id, role: grant.roleName },
approverEmail: policy.approverEmail,
});
}

View File

@@ -0,0 +1,38 @@
// POST /api/pam/revoke/[id] — self-revoke an active grant.
//
// Only the owner can revoke their own grant via this endpoint. An
// admin override path would live elsewhere (e.g. /api/pam/admin/...)
// once we add it.
import { auth } from "@/auth";
import { prisma } from "@/database/prisma";
import { revokeGrant } from "@/lib/pam";
export const dynamic = "force-dynamic";
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const session = await auth();
const user = session?.user as { id?: string } | undefined;
if (!user?.id) return Response.json({ error: "unauthorized" }, { status: 401 });
const { id } = await ctx.params;
const grant = await prisma.privilegeGrant.findUnique({
where: { id },
select: { gscsid: true, status: true },
});
if (!grant) return Response.json({ error: "not_found" }, { status: 404 });
if (grant.gscsid !== user.id) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
let reason: string | null = null;
try {
const body = await req.json().catch(() => ({}));
if (body && typeof body.reason === "string") reason = body.reason.slice(0, 500);
} catch {
/* no body, fine */
}
await revokeGrant(id, user.id, reason);
return Response.json({ status: "revoked" });
}

View File

@@ -0,0 +1,14 @@
import { signIn } from "@/auth";
/**
* Auto-redirect to Keycloak. We have exactly one auth provider, so
* skip NextAuth's provider-list page at /api/auth/signin — the kit's
* middleware (signInPath: "/auth/keycloak") and the NextAuth config
* (pages.signIn) both point here.
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const callbackUrl = url.searchParams.get("callbackUrl") || "/";
await signIn("keycloak", { redirectTo: callbackUrl });
return new Response(null, { status: 302, headers: { Location: callbackUrl } });
}

25
src/app/global-error.tsx Normal file
View File

@@ -0,0 +1,25 @@
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<div className="d-flex align-items-center justify-content-center min-vh-100">
<div className="text-center">
<h4>Something went wrong</h4>
<p className="text-muted mb-4">{error.message || "An unexpected error occurred."}</p>
<button className="btn btn-primary" onClick={() => reset()}>
Try again
</button>
</div>
</div>
</body>
</html>
);
}

41
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import Providers from "./providers";
import { auth } from "@/auth";
// Styles
import "bootstrap/dist/css/bootstrap.min.css";
import "@limitless/ui/css";
import "@/styles/all.min.css";
import "@/styles/sidebar-overrides.css";
import "@/styles/icons/phosphor/styles.min.css";
export const metadata: Metadata = {
title: "GSC My Settings",
description: "GoSec Cloud Personal Settings",
icons: {
icon: "data:image/x-icon;base64,AA",
},
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const locale = await getLocale();
const messages = await getMessages();
const session = await auth();
const accessToken = (session?.user as { accessToken?: string } | undefined)?.accessToken ?? null;
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
<Providers accessToken={accessToken}>{children}</Providers>
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,17 @@
import Link from "next/link";
export default function LoggedOutPage() {
return (
<div className="d-flex align-items-center justify-content-center min-vh-100">
<div className="text-center">
<i className="ph-sign-out ph-3x text-muted mb-3 d-block"></i>
<h4>You have been logged out</h4>
<p className="text-muted mb-4">Your session has been ended.</p>
<Link href="/login" className="btn btn-primary">
<i className="ph-sign-in me-2"></i>
Sign in again
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import { signIn } from "next-auth/react";
import { useEffect } from "react";
export default function AutoLogin({ callbackUrl }: { callbackUrl: string }) {
useEffect(() => {
signIn("keycloak", { callbackUrl });
}, [callbackUrl]);
return null;
}

24
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import AutoLogin from "./AutoLogin";
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ callbackUrl?: string }>;
}) {
const { callbackUrl } = await searchParams;
const destination = callbackUrl || "/";
if (process.env.SKIP_AUTH === "true") {
redirect(destination);
}
const session = await auth();
const hasError = (session as unknown as { error?: string } | null)?.error;
if (session?.user && !hasError) {
redirect(destination);
}
return <AutoLogin callbackUrl={destination} />;
}

17
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,17 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="d-flex align-items-center justify-content-center min-vh-100">
<div className="text-center">
<i className="ph-warning ph-3x text-muted mb-3 d-block"></i>
<h4>Page not found</h4>
<p className="text-muted mb-4">The page you are looking for does not exist.</p>
<Link href="/" className="btn btn-primary">
<i className="ph-house me-2"></i>
Go home
</Link>
</div>
</div>
);
}

37
src/app/providers.tsx Normal file
View File

@@ -0,0 +1,37 @@
"use client";
import { useCallback } from "react";
import { SessionProvider, useSession } from "next-auth/react";
import { ThemeProvider } from "@limitless/ui";
import { ChatProvider } from "@gsc/chat";
interface ProvidersProps {
children: React.ReactNode;
accessToken?: string | null;
}
function ChatProviderWrapper({ children }: { children: React.ReactNode }) {
const { data: session } = useSession();
const getToken = useCallback(async () => {
return (session?.user as { accessToken?: string } | undefined)?.accessToken ?? null;
}, [session]);
return (
<ChatProvider getToken={getToken}>
{children}
</ChatProvider>
);
}
export default function Providers({ children, accessToken }: ProvidersProps) {
return (
<SessionProvider refetchInterval={4 * 60}>
<ThemeProvider>
<ChatProviderWrapper>
{children}
</ChatProviderWrapper>
</ThemeProvider>
</SessionProvider>
);
}

View File

@@ -0,0 +1,23 @@
import Link from "next/link";
export const metadata = { title: "Signed out — GSC My" };
export default function SignedOutPage() {
return (
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-light">
<div className="card shadow-sm" style={{ maxWidth: 480 }}>
<div className="card-body p-4 text-center">
<div className="mb-3">
<i className="ph ph-sign-out text-success" style={{ fontSize: "3rem" }}></i>
</div>
<h4 className="mb-2">You&apos;re signed out</h4>
<p className="text-muted mb-4">Your GSC session has ended.</p>
<div className="d-flex gap-2 justify-content-center">
<Link href="/auth/keycloak" className="btn btn-primary">Sign back in</Link>
<a href="https://gosec.cloud/" className="btn btn-outline-secondary">Back to gosec.cloud</a>
</div>
</div>
</div>
</div>
);
}

36
src/auth.ts Normal file
View File

@@ -0,0 +1,36 @@
// NextAuth v5 bootstrap for gscMy via @gsc/web-kit.
//
// Identity is sourced entirely from Keycloak — FreeIPA owns user
// management, Keycloak federates and authenticates, group membership
// → realm roles → permissions. No local users table writes from
// login. See gsc-identity-boundaries memory + docs/pam-plan.md.
//
// gscMy is the tenant user portal — including the place where users
// request privileged access via the PAM/JIT flow. We intentionally
// do NOT gate signin on a specific role: anyone who can authenticate
// against gosecCloud belongs here.
import { createAuth } from "@gsc/web-kit/auth/server";
export const { handlers, signIn, signOut, auth, requireAuth } = createAuth({
keycloak: {
clientId: process.env.AUTH_KEYCLOAK_ID!,
clientSecret: process.env.AUTH_KEYCLOAK_SECRET!,
issuer: process.env.AUTH_KEYCLOAK_ISSUER!,
},
pages: {
signIn: "/auth/keycloak",
error: "/access-denied",
},
});
// Re-export SessionUser for app code that imports the type.
export type { SessionUser } from "@gsc/web-kit/auth";
// Compatibility helper for legacy callers that used the async
// getAuthenticatedUser() pattern. Kit's `auth()` already returns
// session.user — this just unwraps it for those call sites.
export async function getAuthenticatedUser() {
const session = await auth();
return session?.user ?? null;
}

View File

@@ -0,0 +1,19 @@
"use client";
import { signOut } from "next-auth/react";
export function LogoutButton({ label }: { label: string }) {
const handleLogout = async () => {
const res = await fetch("/api/auth/logout");
const { logoutUrl } = await res.json();
await signOut({ redirect: false });
window.location.href = logoutUrl;
};
return (
<button type="button" onClick={handleLogout} className="dropdown-item">
<i className="ph-sign-out me-2"></i>
{label}
</button>
);
}

View File

@@ -0,0 +1,497 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
interface LoginSession {
id: string;
loginTime: Date;
ipAddress: string;
device: string;
location: string;
status: "success" | "failed";
}
interface SecurityEvent {
id: string;
event: string;
detail: string;
timestamp: Date;
icon: string;
color: string;
}
interface SerializedLoginSession {
id: string;
loginTime: string;
ipAddress: string;
device: string;
location: string;
status: "success" | "failed";
}
interface SerializedSecurityEvent {
id: string;
event: string;
detail: string;
timestamp: string;
icon: string;
color: string;
}
interface AccountSecurityProps {
displayName: string;
email: string;
loginSessions?: SerializedLoginSession[];
securityEvents?: SerializedSecurityEvent[];
twoFactorEnabled?: boolean;
initialLoginNotifications?: boolean;
activeSessionCount?: number;
}
export default function AccountSecurity({
displayName,
email,
loginSessions: propSessions,
securityEvents: propEvents,
twoFactorEnabled: propTwoFactor,
initialLoginNotifications,
activeSessionCount = 0,
}: AccountSecurityProps) {
const [isPending, startTransition] = useTransition();
const [activeTab, setActiveTab] = useState<"overview" | "sessions" | "log">("overview");
const [twoFactorEnabled, setTwoFactorEnabled] = useState(propTwoFactor ?? false);
const [loginNotifications, setLoginNotifications] = useState(initialLoginNotifications ?? true);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Use real Keycloak data if available, otherwise empty
const sessions: LoginSession[] = (propSessions ?? []).map((s) => ({
...s,
loginTime: new Date(s.loginTime),
}));
const securityEventsList: SecurityEvent[] = (propEvents ?? []).map((e) => ({
...e,
timestamp: new Date(e.timestamp),
}));
const formatDateTime = (date: Date) => {
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatRelativeTime = (date: Date) => {
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return formatDateTime(date);
};
const handleSave = () => {
startTransition(async () => {
try {
const { saveSecuritySettings } = await import("@/app/actions/security");
const result = await saveSecuritySettings({ loginNotifications });
if (result.error) {
setMessage({ type: "error", text: result.error });
} else {
setMessage({ type: "success", text: result.message || "Security settings updated successfully." });
}
} catch {
setMessage({ type: "error", text: "Failed to save security settings." });
}
setTimeout(() => setMessage(null), 5000);
});
};
const successLogins = sessions.filter((s) => s.status === "success").length;
const failedLogins = sessions.filter((s) => s.status === "failed").length;
const uniqueDevices = new Set(sessions.map((s) => s.device)).size;
return (
<>
{/* Page Header */}
<div className="d-flex align-items-center justify-content-between mb-4">
<div className="d-flex align-items-center">
<Link href="/" className="btn btn-light btn-sm me-3">
<i className="ph-arrow-left"></i>
</Link>
<div>
<h4 className="mb-0">
<i className="ph-shield-check me-2"></i>
Account Security
</h4>
<p className="text-muted mb-0 mt-1">Manage your security settings, sessions, and authentication methods.</p>
</div>
</div>
<button className="btn btn-primary" onClick={handleSave} disabled={isPending}>
{isPending ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Saving...
</>
) : (
<>
<i className="ph-floppy-disk me-2"></i>
Save Changes
</>
)}
</button>
</div>
{/* Alert */}
{message && (
<div
className={`alert ${message.type === "success" ? "alert-success" : "alert-danger"} alert-dismissible`}
role="alert"
>
<i className={`ph-${message.type === "success" ? "check-circle" : "warning"} me-2`}></i>
{message.text}
<button type="button" className="btn-close" onClick={() => setMessage(null)}></button>
</div>
)}
{/* Summary Cards */}
<div className="row mb-4">
<div className="col-sm-6 col-xl-3">
<div className="card card-body">
<div className="d-flex align-items-center">
<div className="flex-fill">
<h4 className="mb-0">{successLogins}</h4>
<span className="text-muted">Successful Logins</span>
</div>
<i className="ph-sign-in ph-2x text-success opacity-75"></i>
</div>
</div>
</div>
<div className="col-sm-6 col-xl-3">
<div className="card card-body">
<div className="d-flex align-items-center">
<div className="flex-fill">
<h4 className="mb-0">{failedLogins}</h4>
<span className="text-muted">Failed Attempts</span>
</div>
<i className="ph-warning ph-2x text-danger opacity-75"></i>
</div>
</div>
</div>
<div className="col-sm-6 col-xl-3">
<div className="card card-body">
<div className="d-flex align-items-center">
<div className="flex-fill">
<h4 className="mb-0">{uniqueDevices}</h4>
<span className="text-muted">Known Devices</span>
</div>
<i className="ph-devices ph-2x text-primary opacity-75"></i>
</div>
</div>
</div>
<div className="col-sm-6 col-xl-3">
<div className="card card-body">
<div className="d-flex align-items-center">
<div className="flex-fill">
<h4 className="mb-0">{twoFactorEnabled ? "On" : "Off"}</h4>
<span className="text-muted">Two-Factor Auth</span>
</div>
<i className={`ph-shield-check ph-2x ${twoFactorEnabled ? "text-success" : "text-warning"} opacity-75`}></i>
</div>
</div>
</div>
</div>
<div className="row">
{/* Left Column */}
<div className="col-xl-8">
{/* Tabs Card */}
<div className="card">
<div className="card-header">
<ul className="nav nav-tabs card-header-tabs">
<li className="nav-item">
<button
className={`nav-link ${activeTab === "overview" ? "active" : ""}`}
onClick={() => setActiveTab("overview")}
>
<i className="ph-shield me-2"></i>
Security Settings
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "sessions" ? "active" : ""}`}
onClick={() => setActiveTab("sessions")}
>
<i className="ph-devices me-2"></i>
Login Sessions
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "log" ? "active" : ""}`}
onClick={() => setActiveTab("log")}
>
<i className="ph-clock-counter-clockwise me-2"></i>
Security Log
</button>
</li>
</ul>
</div>
<div className="card-body">
{/* Security Settings Tab */}
{activeTab === "overview" && (
<>
<h5 className="mb-3">
<i className="ph-key me-2"></i>
Password
</h5>
<div className="d-flex align-items-center p-3 bg-light rounded mb-4">
<div className="bg-success bg-opacity-10 text-success rounded-pill p-2 me-3">
<i className="ph-check"></i>
</div>
<div className="flex-fill">
<div className="fw-medium">Password is set</div>
<small className="text-muted">Managed by your identity provider</small>
</div>
<a
href={`${process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER || "https://auth.gosec.cloud/realms/gosecCloud"}/account/#/security/signingin`}
target="_blank"
rel="noreferrer"
className="btn btn-outline-primary btn-sm"
>
<i className="ph-key me-1"></i>
Change Password
</a>
</div>
<h5 className="mb-3">
<i className="ph-shield-check me-2"></i>
Two-Factor Authentication
</h5>
<div className="d-flex justify-content-between align-items-start py-3 border-bottom">
<div className="me-3">
<div className="fw-medium">Enable two-factor authentication</div>
<small className="text-muted">Add an extra layer of security with TOTP authenticator app</small>
</div>
<label className="form-check form-switch mb-0">
<input
type="checkbox"
className="form-check-input"
checked={twoFactorEnabled}
onChange={(e) => setTwoFactorEnabled(e.target.checked)}
/>
</label>
</div>
<div className="d-flex justify-content-between align-items-start py-3 border-bottom">
<div className="me-3">
<div className="fw-medium">Login notifications</div>
<small className="text-muted">Get notified when someone logs into your account from a new device</small>
</div>
<label className="form-check form-switch mb-0">
<input
type="checkbox"
className="form-check-input"
checked={loginNotifications}
onChange={(e) => setLoginNotifications(e.target.checked)}
/>
</label>
</div>
{!twoFactorEnabled && (
<div className="alert alert-warning mt-3 mb-0">
<i className="ph-warning me-2"></i>
Two-factor authentication is not enabled. We strongly recommend enabling it for better account protection.
</div>
)}
</>
)}
{/* Sessions Tab */}
{activeTab === "sessions" && (
<>
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">
<i className="ph-clock me-2"></i>
Recent Login Sessions
</h5>
{failedLogins > 0 && (
<span className="badge bg-danger">
{failedLogins} failed attempt{failedLogins > 1 ? "s" : ""}
</span>
)}
</div>
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th>Time</th>
<th>Device</th>
<th>IP Address</th>
<th>Location</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{sessions.map((session) => (
<tr key={session.id}>
<td>
<span className="fw-medium">{formatDateTime(session.loginTime)}</span>
<small className="d-block text-muted">{formatRelativeTime(session.loginTime)}</small>
</td>
<td>
<i className="ph-device-mobile me-1"></i>
{session.device}
</td>
<td>
<code>{session.ipAddress}</code>
</td>
<td>{session.location}</td>
<td>
{session.status === "success" ? (
<span className="badge bg-success bg-opacity-10 text-success">
<i className="ph-check me-1"></i>
Success
</span>
) : (
<span className="badge bg-danger bg-opacity-10 text-danger">
<i className="ph-x me-1"></i>
Failed
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{/* Security Log Tab */}
{activeTab === "log" && (
<>
<h5 className="mb-3">
<i className="ph-list-bullets me-2"></i>
Security Events
</h5>
<div className="timeline">
{securityEventsList.map((event) => (
<div key={event.id} className="d-flex mb-3">
<div className="me-3">
<div className={`bg-${event.color} bg-opacity-10 text-${event.color} rounded-pill p-2`}>
<i className={event.icon}></i>
</div>
</div>
<div className="flex-fill">
<div className="fw-medium">
{event.event} <span className="text-muted fw-normal">- {event.detail}</span>
</div>
<small className="text-muted">{formatRelativeTime(event.timestamp)}</small>
</div>
</div>
))}
</div>
</>
)}
</div>
</div>
</div>
{/* Right Column */}
<div className="col-xl-4">
{/* Security Status */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="ph-shield me-2"></i>
Security Status
</h5>
</div>
<div className="card-body">
<div className="d-flex align-items-center mb-3">
<div className={`bg-${twoFactorEnabled ? "success" : "warning"} bg-opacity-10 text-${twoFactorEnabled ? "success" : "warning"} rounded-pill p-2 me-3`}>
<i className={`ph-${twoFactorEnabled ? "check" : "warning"}`}></i>
</div>
<div>
<div className="fw-medium">Two-Factor Auth</div>
<small className={twoFactorEnabled ? "text-success" : "text-warning"}>
{twoFactorEnabled ? "Enabled" : "Not enabled"}
</small>
</div>
</div>
<div className="d-flex align-items-center mb-3">
<div className="bg-success bg-opacity-10 text-success rounded-pill p-2 me-3">
<i className="ph-check"></i>
</div>
<div>
<div className="fw-medium">Password</div>
<small className="text-success">Set and active</small>
</div>
</div>
<div className="d-flex align-items-center mb-3">
<div className={`bg-${loginNotifications ? "success" : "warning"} bg-opacity-10 text-${loginNotifications ? "success" : "warning"} rounded-pill p-2 me-3`}>
<i className={`ph-${loginNotifications ? "check" : "warning"}`}></i>
</div>
<div>
<div className="fw-medium">Login Notifications</div>
<small className={loginNotifications ? "text-success" : "text-warning"}>
{loginNotifications ? "Enabled" : "Disabled"}
</small>
</div>
</div>
<div className="d-flex align-items-center">
<div className="bg-success bg-opacity-10 text-success rounded-pill p-2 me-3">
<i className="ph-check"></i>
</div>
<div>
<div className="fw-medium">Active Sessions</div>
<small className="text-success">
{activeSessionCount} active session{activeSessionCount !== 1 ? "s" : ""}
</small>
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="ph-lightning me-2"></i>
Quick Actions
</h5>
</div>
<div className="card-body">
<div className="d-grid gap-2">
<button className="btn btn-light text-start">
<i className="ph-sign-out me-2"></i>
Sign Out All Devices
</button>
<button className="btn btn-light text-start">
<i className="ph-download me-2"></i>
Export Security Log
</button>
<Link href="/account/privacy" className="btn btn-light text-start">
<i className="ph-lock-key me-2"></i>
Privacy Settings
</Link>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,568 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
interface AccountSettingsState {
language: string;
timezone: string;
dateFormat: string;
theme: "light" | "dark" | "system";
notifyEmail: boolean;
notifyBrowser: boolean;
notifyActivity: boolean;
notifySecurityAlerts: boolean;
notifyProductUpdates: boolean;
compactMode: boolean;
showWelcomeScreen: boolean;
defaultLandingPage: string;
// Email settings (from gscWorkplace)
messagesPerPage: string;
defaultReplyMode: string;
emailSignature: string;
composeFormat: string;
readReceipts: boolean;
// Calendar settings (from gscWorkplace)
calendarDefaultView: string;
weekStartsOn: string;
workingHoursStart: string;
workingHoursEnd: string;
defaultReminder: string;
defaultEventDuration: string;
}
const defaultSettings: AccountSettingsState = {
language: "en",
timezone: "Europe/Helsinki",
dateFormat: "DD/MM/YYYY",
theme: "light",
notifyEmail: true,
notifyBrowser: true,
notifyActivity: true,
notifySecurityAlerts: true,
notifyProductUpdates: false,
compactMode: false,
showWelcomeScreen: true,
defaultLandingPage: "/",
// Email defaults
messagesPerPage: "50",
defaultReplyMode: "reply",
emailSignature: "",
composeFormat: "html",
readReceipts: true,
// Calendar defaults
calendarDefaultView: "week",
weekStartsOn: "monday",
workingHoursStart: "08:00",
workingHoursEnd: "17:00",
defaultReminder: "15",
defaultEventDuration: "60",
};
interface AccountSettingsProps {
displayName: string;
email: string;
initialSettings?: Record<string, unknown>;
}
export default function AccountSettings({ displayName, email, initialSettings }: AccountSettingsProps) {
const [isPending, startTransition] = useTransition();
const [settings, setSettings] = useState<AccountSettingsState>(() => {
if (!initialSettings) return defaultSettings;
return {
...defaultSettings,
...Object.fromEntries(
Object.entries(initialSettings).filter(([k]) => k in defaultSettings)
),
} as AccountSettingsState;
});
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [activeSection, setActiveSection] = useState<string | null>(null);
const updateSetting = <K extends keyof AccountSettingsState>(key: K, value: AccountSettingsState[K]) => {
setSettings((prev) => ({ ...prev, [key]: value }));
};
const handleSave = () => {
startTransition(async () => {
try {
const { saveAccountSettings } = await import("@/app/actions/settings");
const result = await saveAccountSettings(settings as unknown as Record<string, unknown>);
if (result.error) {
setMessage({ type: "error", text: result.error });
} else {
setMessage({ type: "success", text: result.message || "Settings saved successfully." });
}
} catch {
setMessage({ type: "error", text: "Failed to save settings." });
}
setTimeout(() => setMessage(null), 5000);
});
};
const SettingSwitch = ({
label,
description,
checked,
onChange,
}: {
label: string;
description?: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) => (
<div className="d-flex justify-content-between align-items-start py-3 border-bottom">
<div className="me-3">
<div className="fw-medium">{label}</div>
{description && <small className="text-muted">{description}</small>}
</div>
<label className="form-check form-switch mb-0">
<input
type="checkbox"
className="form-check-input"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
</label>
</div>
);
return (
<>
{/* Page Header */}
<div className="d-flex align-items-center justify-content-between mb-4">
<div className="d-flex align-items-center">
<Link href="/" className="btn btn-light btn-sm me-3">
<i className="ph-arrow-left"></i>
</Link>
<div>
<h4 className="mb-0">
<i className="ph-gear me-2"></i>
Account Settings
</h4>
<p className="text-muted mb-0 mt-1">Manage your account preferences and display settings.</p>
</div>
</div>
<button className="btn btn-primary" onClick={handleSave} disabled={isPending}>
{isPending ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Saving...
</>
) : (
<>
<i className="ph-floppy-disk me-2"></i>
Save Changes
</>
)}
</button>
</div>
{/* Alert */}
{message && (
<div
className={`alert ${message.type === "success" ? "alert-success" : "alert-danger"} alert-dismissible`}
role="alert"
>
<i className={`ph-${message.type === "success" ? "check-circle" : "warning"} me-2`}></i>
{message.text}
<button type="button" className="btn-close" onClick={() => setMessage(null)}></button>
</div>
)}
<div className="row">
{/* Left Column - Settings */}
<div className="col-xl-8">
{/* General Settings */}
<div className="card">
<div
className="card-header d-flex align-items-center"
onClick={() => setActiveSection(activeSection === "general" ? null : "general")}
style={{ cursor: "pointer" }}
>
<i className="ph-sliders-horizontal me-2 text-primary"></i>
<h5 className="mb-0 flex-fill">General</h5>
<i className={`ph-caret-${activeSection === "general" ? "up" : "down"}`}></i>
</div>
<div className={`card-body ${activeSection !== null && activeSection !== "general" ? "d-none" : ""}`}>
<div className="row g-3">
<div className="col-md-6">
<label className="form-label">Language</label>
<select
className="form-select"
value={settings.language}
onChange={(e) => updateSetting("language", e.target.value)}
>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Fran&ccedil;ais</option>
</select>
<small className="form-text text-muted">Display language for the portal interface</small>
</div>
<div className="col-md-6">
<label className="form-label">Timezone</label>
<select
className="form-select"
value={settings.timezone}
onChange={(e) => updateSetting("timezone", e.target.value)}
>
<option value="Europe/Helsinki">Europe/Helsinki (EET)</option>
<option value="Europe/Berlin">Europe/Berlin (CET)</option>
<option value="Europe/London">Europe/London (GMT)</option>
<option value="America/New_York">America/New York (EST)</option>
<option value="America/Los_Angeles">America/Los Angeles (PST)</option>
<option value="Asia/Tokyo">Asia/Tokyo (JST)</option>
<option value="UTC">UTC</option>
</select>
<small className="form-text text-muted">Used for timestamps and scheduling</small>
</div>
<div className="col-md-6">
<label className="form-label">Date Format</label>
<select
className="form-select"
value={settings.dateFormat}
onChange={(e) => updateSetting("dateFormat", e.target.value)}
>
<option value="DD/MM/YYYY">DD/MM/YYYY</option>
<option value="MM/DD/YYYY">MM/DD/YYYY</option>
<option value="YYYY-MM-DD">YYYY-MM-DD (ISO)</option>
</select>
</div>
<div className="col-md-6">
<label className="form-label">Default Landing Page</label>
<select
className="form-select"
value={settings.defaultLandingPage}
onChange={(e) => updateSetting("defaultLandingPage", e.target.value)}
>
<option value="/">Dashboard</option>
<option value="/marketplace">Marketplace</option>
<option value="/account/profile">Profile</option>
</select>
</div>
</div>
</div>
</div>
{/* Appearance */}
<div className="card">
<div
className="card-header d-flex align-items-center"
onClick={() => setActiveSection(activeSection === "appearance" ? null : "appearance")}
style={{ cursor: "pointer" }}
>
<i className="ph-palette me-2 text-info"></i>
<h5 className="mb-0 flex-fill">Appearance</h5>
<i className={`ph-caret-${activeSection === "appearance" ? "up" : "down"}`}></i>
</div>
<div className={`card-body ${activeSection !== null && activeSection !== "appearance" ? "d-none" : ""}`}>
<label className="form-label mb-3">Theme</label>
<div className="d-flex gap-2 flex-wrap mb-4">
{[
{ value: "light", label: "Light", icon: "ph-sun", desc: "Light background" },
{ value: "dark", label: "Dark", icon: "ph-moon", desc: "Dark background" },
{ value: "system", label: "System", icon: "ph-monitor", desc: "Match OS setting" },
].map((option) => (
<div
key={option.value}
className={`card flex-fill ${settings.theme === option.value ? "border-primary" : ""}`}
style={{ cursor: "pointer", minWidth: "140px" }}
onClick={() => updateSetting("theme", option.value as "light" | "dark" | "system")}
>
<div className="card-body text-center py-3">
<i className={`${option.icon} fs-2 ${settings.theme === option.value ? "text-primary" : "text-muted"} mb-2 d-block`}></i>
<div className="fw-medium">{option.label}</div>
<small className="text-muted">{option.desc}</small>
</div>
</div>
))}
</div>
<SettingSwitch
label="Compact mode"
description="Reduce spacing for a more condensed layout"
checked={settings.compactMode}
onChange={(v) => updateSetting("compactMode", v)}
/>
<SettingSwitch
label="Show welcome screen"
description="Display the welcome greeting on the dashboard"
checked={settings.showWelcomeScreen}
onChange={(v) => updateSetting("showWelcomeScreen", v)}
/>
</div>
</div>
{/* Email Display Settings */}
<div className="card">
<div
className="card-header d-flex align-items-center"
onClick={() => setActiveSection(activeSection === "email" ? null : "email")}
style={{ cursor: "pointer" }}
>
<i className="ph-envelope me-2 text-success"></i>
<h5 className="mb-0 flex-fill">Email</h5>
<i className={`ph-caret-${activeSection === "email" ? "up" : "down"}`}></i>
</div>
<div className={`card-body ${activeSection !== null && activeSection !== "email" ? "d-none" : ""}`}>
<div className="row mb-3">
<div className="col-md-6">
<label className="form-label">Messages per page</label>
<select
className="form-select"
value={settings.messagesPerPage}
onChange={(e) => updateSetting("messagesPerPage", e.target.value)}
>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div className="col-md-6">
<label className="form-label">Default reply mode</label>
<select
className="form-select"
value={settings.defaultReplyMode}
onChange={(e) => updateSetting("defaultReplyMode", e.target.value)}
>
<option value="reply">Reply</option>
<option value="replyAll">Reply All</option>
</select>
</div>
</div>
<div className="row mb-3">
<div className="col-md-6">
<label className="form-label">Email signature</label>
<textarea
className="form-control"
rows={3}
placeholder="Enter your email signature..."
value={settings.emailSignature}
onChange={(e) => updateSetting("emailSignature", e.target.value)}
></textarea>
</div>
<div className="col-md-6">
<label className="form-label">Compose format</label>
<select
className="form-select mb-3"
value={settings.composeFormat}
onChange={(e) => updateSetting("composeFormat", e.target.value)}
>
<option value="html">Rich Text (HTML)</option>
<option value="plain">Plain Text</option>
</select>
<SettingSwitch
label="Request read receipts"
checked={settings.readReceipts}
onChange={(v) => updateSetting("readReceipts", v)}
/>
</div>
</div>
</div>
</div>
{/* Calendar Settings */}
<div className="card">
<div
className="card-header d-flex align-items-center"
onClick={() => setActiveSection(activeSection === "calendar" ? null : "calendar")}
style={{ cursor: "pointer" }}
>
<i className="ph-calendar me-2 text-purple"></i>
<h5 className="mb-0 flex-fill">Calendar</h5>
<i className={`ph-caret-${activeSection === "calendar" ? "up" : "down"}`}></i>
</div>
<div className={`card-body ${activeSection !== null && activeSection !== "calendar" ? "d-none" : ""}`}>
<div className="row mb-3">
<div className="col-md-6">
<label className="form-label">Default view</label>
<select
className="form-select"
value={settings.calendarDefaultView}
onChange={(e) => updateSetting("calendarDefaultView", e.target.value)}
>
<option value="month">Month</option>
<option value="week">Week</option>
<option value="day">Day</option>
<option value="agenda">Agenda</option>
</select>
</div>
<div className="col-md-6">
<label className="form-label">Week starts on</label>
<select
className="form-select"
value={settings.weekStartsOn}
onChange={(e) => updateSetting("weekStartsOn", e.target.value)}
>
<option value="monday">Monday</option>
<option value="sunday">Sunday</option>
<option value="saturday">Saturday</option>
</select>
</div>
</div>
<div className="row mb-3">
<div className="col-md-6">
<label className="form-label">Working hours start</label>
<input
type="time"
className="form-control"
value={settings.workingHoursStart}
onChange={(e) => updateSetting("workingHoursStart", e.target.value)}
/>
</div>
<div className="col-md-6">
<label className="form-label">Working hours end</label>
<input
type="time"
className="form-control"
value={settings.workingHoursEnd}
onChange={(e) => updateSetting("workingHoursEnd", e.target.value)}
/>
</div>
</div>
<div className="row">
<div className="col-md-6">
<label className="form-label">Default reminder</label>
<select
className="form-select"
value={settings.defaultReminder}
onChange={(e) => updateSetting("defaultReminder", e.target.value)}
>
<option value="5">5 minutes before</option>
<option value="10">10 minutes before</option>
<option value="15">15 minutes before</option>
<option value="30">30 minutes before</option>
<option value="60">1 hour before</option>
</select>
</div>
<div className="col-md-6">
<label className="form-label">Default event duration</label>
<select
className="form-select"
value={settings.defaultEventDuration}
onChange={(e) => updateSetting("defaultEventDuration", e.target.value)}
>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">1 hour</option>
<option value="90">1.5 hours</option>
</select>
</div>
</div>
</div>
</div>
{/* Notifications */}
<div className="card">
<div
className="card-header d-flex align-items-center"
onClick={() => setActiveSection(activeSection === "notifications" ? null : "notifications")}
style={{ cursor: "pointer" }}
>
<i className="ph-bell me-2 text-warning"></i>
<h5 className="mb-0 flex-fill">Notifications</h5>
<i className={`ph-caret-${activeSection === "notifications" ? "up" : "down"}`}></i>
</div>
<div className={`card-body ${activeSection !== null && activeSection !== "notifications" ? "d-none" : ""}`}>
<SettingSwitch
label="Email notifications"
description="Receive important updates via email"
checked={settings.notifyEmail}
onChange={(v) => updateSetting("notifyEmail", v)}
/>
<SettingSwitch
label="Browser notifications"
description="Show desktop push notifications"
checked={settings.notifyBrowser}
onChange={(v) => updateSetting("notifyBrowser", v)}
/>
<SettingSwitch
label="Activity notifications"
description="Get notified about mentions, comments, and shared items"
checked={settings.notifyActivity}
onChange={(v) => updateSetting("notifyActivity", v)}
/>
<SettingSwitch
label="Security alerts"
description="Notify about suspicious activity and login attempts"
checked={settings.notifySecurityAlerts}
onChange={(v) => updateSetting("notifySecurityAlerts", v)}
/>
<SettingSwitch
label="Product updates"
description="Get notified about new features and improvements"
checked={settings.notifyProductUpdates}
onChange={(v) => updateSetting("notifyProductUpdates", v)}
/>
</div>
</div>
</div>
{/* Right Column */}
<div className="col-xl-4">
{/* Account Info */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="ph-user me-2"></i>
Account
</h5>
</div>
<div className="card-body">
<div className="list-group list-group-flush">
<div className="list-group-item d-flex justify-content-between px-0">
<span className="text-muted">Name</span>
<span className="fw-medium">{displayName}</span>
</div>
<div className="list-group-item d-flex justify-content-between px-0">
<span className="text-muted">Email</span>
<span className="fw-medium">{email}</span>
</div>
<div className="list-group-item d-flex justify-content-between px-0">
<span className="text-muted">Language</span>
<span className="fw-medium">{settings.language.toUpperCase()}</span>
</div>
<div className="list-group-item d-flex justify-content-between px-0">
<span className="text-muted">Theme</span>
<span className="fw-medium text-capitalize">{settings.theme}</span>
</div>
</div>
</div>
<div className="card-footer">
<Link href="/profile" className="btn btn-outline-primary btn-sm w-100">
<i className="ph-user-circle me-2"></i>
View Full Profile
</Link>
</div>
</div>
{/* Related Pages */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="ph-link me-2"></i>
Related
</h5>
</div>
<div className="card-body">
<div className="d-grid gap-2">
<Link href="/security" className="btn btn-light text-start">
<i className="ph-shield-check me-2 text-danger"></i>
Account Security
</Link>
<Link href="/privacy" className="btn btn-light text-start">
<i className="ph-lock-key me-2 text-success"></i>
Privacy Settings
</Link>
<Link href="/agent" className="btn btn-light text-start">
<i className="ph-robot me-2 text-purple"></i>
AI Agent Settings
</Link>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,504 @@
"use client";
import { useState } from "react";
import Link from "next/link";
interface LoginSession {
id: string;
loginTime: Date;
ipAddress: string;
device: string;
location: string;
status: "success" | "failed";
}
interface ActivityItem {
id: string;
action: string;
target: string;
timestamp: Date;
icon: string;
color: string;
}
interface SerializedLoginSession {
id: string;
loginTime: string;
ipAddress: string;
device: string;
location: string;
status: "success" | "failed";
}
interface SerializedActivityItem {
id: string;
action: string;
target: string;
timestamp: string;
icon: string;
color: string;
}
interface UserAnalyticsProps {
userName: string;
userEmail: string;
loginSessions?: SerializedLoginSession[];
recentActivity?: SerializedActivityItem[];
memberSince?: string | null;
twoFactorEnabled?: boolean;
activeSessionCount?: number;
}
export default function UserAnalytics({
userName,
userEmail,
loginSessions: propLoginSessions,
recentActivity: propRecentActivity,
memberSince: propMemberSince,
twoFactorEnabled = false,
activeSessionCount = 0,
}: UserAnalyticsProps) {
const [dateRange, setDateRange] = useState("30d");
const [activeTab, setActiveTab] = useState<"overview" | "logins" | "activity">("overview");
const memberSince = propMemberSince ? new Date(propMemberSince) : new Date();
// Convert serialized data to Date objects
const loginSessionsData: LoginSession[] = (propLoginSessions ?? []).map((s) => ({
...s,
loginTime: new Date(s.loginTime),
}));
const recentActivityData: ActivityItem[] = (propRecentActivity ?? []).map((a) => ({
...a,
timestamp: new Date(a.timestamp),
}));
const formatDate = (date: Date) => {
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
const formatDateTime = (date: Date) => {
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatRelativeTime = (date: Date) => {
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return formatDate(date);
};
const totalLogins = loginSessionsData.filter((s) => s.status === "success").length;
const failedLogins = loginSessionsData.filter((s) => s.status === "failed").length;
const uniqueDevices = new Set(loginSessionsData.map((s) => s.device)).size;
const daysSinceMember = Math.floor((Date.now() - memberSince.getTime()) / 86400000);
return (
<>
{/* Page Header */}
<div className="d-flex align-items-center justify-content-between mb-4">
<div className="d-flex align-items-center">
<Link href="/" className="btn btn-light btn-sm me-3">
<i className="ph-arrow-left"></i>
</Link>
<div>
<h4 className="mb-0">
<i className="ph-chart-line-up me-2"></i>
My Analytics
</h4>
<p className="text-muted mb-0 mt-1">View your account activity, usage statistics, and login history.</p>
</div>
</div>
<select
className="form-select"
style={{ width: "auto" }}
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
<option value="1y">Last year</option>
</select>
</div>
{/* Summary Cards */}
<div className="row mb-4">
<div className="col-sm-6 col-xl-3">
<div className="card card-body">
<div className="d-flex align-items-center">
<div className="flex-fill">
<h4 className="mb-0">{totalLogins}</h4>
<span className="text-muted">Successful Logins</span>
</div>
<i className="ph-sign-in ph-2x text-success opacity-75"></i>
</div>
</div>
</div>
<div className="col-sm-6 col-xl-3">
<div className="card card-body">
<div className="d-flex align-items-center">
<div className="flex-fill">
<h4 className="mb-0">{uniqueDevices}</h4>
<span className="text-muted">Devices Used</span>
</div>
<i className="ph-devices ph-2x text-primary opacity-75"></i>
</div>
</div>
</div>
<div className="col-sm-6 col-xl-3">
<div className="card card-body">
<div className="d-flex align-items-center">
<div className="flex-fill">
<h4 className="mb-0">{recentActivityData.length}</h4>
<span className="text-muted">Recent Actions</span>
</div>
<i className="ph-activity ph-2x text-info opacity-75"></i>
</div>
</div>
</div>
<div className="col-sm-6 col-xl-3">
<div className="card card-body">
<div className="d-flex align-items-center">
<div className="flex-fill">
<h4 className="mb-0">{daysSinceMember}</h4>
<span className="text-muted">Days as Member</span>
</div>
<i className="ph-calendar-check ph-2x text-warning opacity-75"></i>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="row">
{/* Left Column */}
<div className="col-xl-8">
<div className="card">
<div className="card-header">
<ul className="nav nav-tabs card-header-tabs">
<li className="nav-item">
<button
className={`nav-link ${activeTab === "overview" ? "active" : ""}`}
onClick={() => setActiveTab("overview")}
>
<i className="ph-chart-pie me-2"></i>
Overview
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "logins" ? "active" : ""}`}
onClick={() => setActiveTab("logins")}
>
<i className="ph-sign-in me-2"></i>
Login History
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "activity" ? "active" : ""}`}
onClick={() => setActiveTab("activity")}
>
<i className="ph-clock-counter-clockwise me-2"></i>
Activity Log
</button>
</li>
</ul>
</div>
<div className="card-body">
{/* Overview Tab */}
{activeTab === "overview" && (
<>
<h5 className="mb-3">
<i className="ph-chart-bar me-2"></i>
Login Summary
</h5>
<div className="row mb-4">
<div className="col-md-4">
<div className="card bg-light border-0">
<div className="card-body text-center">
<h3 className="mb-1 text-success">{totalLogins}</h3>
<small className="text-muted">Successful Logins</small>
</div>
</div>
</div>
<div className="col-md-4">
<div className="card bg-light border-0">
<div className="card-body text-center">
<h3 className={`mb-1 ${failedLogins > 0 ? "text-danger" : ""}`}>{failedLogins}</h3>
<small className="text-muted">Failed Attempts</small>
</div>
</div>
</div>
<div className="col-md-4">
<div className="card bg-light border-0">
<div className="card-body text-center">
<h3 className="mb-1 text-primary">{uniqueDevices}</h3>
<small className="text-muted">Unique Devices</small>
</div>
</div>
</div>
</div>
{loginSessionsData.length > 0 && (
<>
<h5 className="mb-3">
<i className="ph-clock me-2"></i>
Latest Logins
</h5>
<div className="table-responsive">
<table className="table table-hover">
<thead>
<tr>
<th>Time</th>
<th>Device</th>
<th>IP Address</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{loginSessionsData.slice(0, 5).map((session) => (
<tr key={session.id}>
<td>{formatRelativeTime(session.loginTime)}</td>
<td>{session.device}</td>
<td><code>{session.ipAddress}</code></td>
<td>
<span className={`badge bg-${session.status === "success" ? "success" : "danger"} bg-opacity-10 text-${session.status === "success" ? "success" : "danger"}`}>
{session.status === "success" ? "Success" : "Failed"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{loginSessionsData.length === 0 && recentActivityData.length === 0 && (
<div className="text-center text-muted py-4">
<i className="ph-chart-line-up fs-1 d-block mb-2 opacity-50"></i>
<p className="mb-0">No activity data available yet.</p>
</div>
)}
</>
)}
{/* Login History Tab */}
{activeTab === "logins" && (
<>
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">
<i className="ph-clock me-2"></i>
Recent Login Sessions
</h5>
{failedLogins > 0 && (
<span className="badge bg-danger">
{failedLogins} failed attempt{failedLogins > 1 ? "s" : ""}
</span>
)}
</div>
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th>Time</th>
<th>Device</th>
<th>IP Address</th>
<th>Location</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{loginSessionsData.map((session) => (
<tr key={session.id}>
<td>
<span className="fw-medium">{formatDateTime(session.loginTime)}</span>
<small className="d-block text-muted">{formatRelativeTime(session.loginTime)}</small>
</td>
<td>
<i className="ph-device-mobile me-1"></i>
{session.device}
</td>
<td>
<code>{session.ipAddress}</code>
</td>
<td>{session.location}</td>
<td>
{session.status === "success" ? (
<span className="badge bg-success bg-opacity-10 text-success">
<i className="ph-check me-1"></i>
Success
</span>
) : (
<span className="badge bg-danger bg-opacity-10 text-danger">
<i className="ph-x me-1"></i>
Failed
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{/* Activity Log Tab */}
{activeTab === "activity" && (
<>
<h5 className="mb-3">
<i className="ph-list-bullets me-2"></i>
Recent Activity
</h5>
<div className="timeline">
{recentActivityData.map((activity) => (
<div key={activity.id} className="d-flex mb-3">
<div className="me-3">
<div className={`bg-${activity.color} bg-opacity-10 text-${activity.color} rounded-pill p-2`}>
<i className={activity.icon}></i>
</div>
</div>
<div className="flex-fill">
<div className="fw-medium">
{activity.action} <span className="text-primary">{activity.target}</span>
</div>
<small className="text-muted">{formatRelativeTime(activity.timestamp)}</small>
</div>
</div>
))}
</div>
</>
)}
</div>
</div>
</div>
{/* Right Column */}
<div className="col-xl-4">
{/* Account Info Card */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="ph-user me-2"></i>
Account Information
</h5>
</div>
<div className="card-body">
<div className="text-center mb-4">
<div
className="bg-primary bg-opacity-10 text-primary rounded-circle d-inline-flex align-items-center justify-content-center"
style={{ width: "80px", height: "80px" }}
>
<i className="ph-user ph-2x"></i>
</div>
<h5 className="mt-3 mb-0">{userName}</h5>
<p className="text-muted">{userEmail}</p>
</div>
<div className="list-group list-group-flush">
<div className="list-group-item d-flex justify-content-between px-0">
<span className="text-muted">Member since</span>
<span className="fw-medium">{formatDate(memberSince)}</span>
</div>
<div className="list-group-item d-flex justify-content-between px-0">
<span className="text-muted">Total logins</span>
<span className="fw-medium">{totalLogins}</span>
</div>
<div className="list-group-item d-flex justify-content-between px-0">
<span className="text-muted">Failed attempts</span>
<span className={`fw-medium ${failedLogins > 0 ? "text-danger" : ""}`}>{failedLogins}</span>
</div>
</div>
</div>
</div>
{/* Security Status Card */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="ph-shield-check me-2"></i>
Security Status
</h5>
</div>
<div className="card-body">
<div className="d-flex align-items-center mb-3">
<div className={`bg-${twoFactorEnabled ? "success" : "warning"} bg-opacity-10 text-${twoFactorEnabled ? "success" : "warning"} rounded-pill p-2 me-3`}>
<i className={`ph-${twoFactorEnabled ? "check" : "warning"}`}></i>
</div>
<div>
<div className="fw-medium">Two-Factor Authentication</div>
<small className={twoFactorEnabled ? "text-success" : "text-warning"}>
{twoFactorEnabled ? "Enabled" : "Not enabled"}
</small>
</div>
</div>
<div className="d-flex align-items-center">
<div className="bg-success bg-opacity-10 text-success rounded-pill p-2 me-3">
<i className="ph-check"></i>
</div>
<div>
<div className="fw-medium">Active Sessions</div>
<small className="text-success">
{activeSessionCount} active session{activeSessionCount !== 1 ? "s" : ""}
</small>
</div>
</div>
</div>
<div className="card-footer">
<Link href="/security" className="btn btn-outline-primary btn-sm w-100">
<i className="ph-gear me-2"></i>
Manage Security Settings
</Link>
</div>
</div>
{/* Quick Actions Card */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="ph-lightning me-2"></i>
Quick Actions
</h5>
</div>
<div className="card-body">
<div className="d-grid gap-2">
<button className="btn btn-light text-start">
<i className="ph-download me-2"></i>
Export Activity Log
</button>
<button className="btn btn-light text-start">
<i className="ph-sign-out me-2"></i>
Sign Out All Devices
</button>
<button className="btn btn-light text-start">
<i className="ph-trash me-2"></i>
Clear Activity History
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,475 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
interface PrivacySettings {
profileVisibility: "public" | "contacts" | "private";
showEmail: boolean;
showPhone: boolean;
showLocation: boolean;
showOnlineStatus: boolean;
allowDirectMessages: boolean;
allowGroupInvites: boolean;
allowMentions: boolean;
allowAnalytics: boolean;
allowPersonalization: boolean;
allowThirdPartyIntegrations: boolean;
emailMarketing: boolean;
emailProductUpdates: boolean;
emailSecurityAlerts: boolean;
emailActivityDigest: boolean;
activityHistoryRetention: "30d" | "90d" | "1y" | "forever";
searchHistoryEnabled: boolean;
twoFactorEnabled: boolean;
loginNotifications: boolean;
sessionTimeout: number;
}
const defaultSettings: PrivacySettings = {
profileVisibility: "contacts",
showEmail: false,
showPhone: false,
showLocation: true,
showOnlineStatus: true,
allowDirectMessages: true,
allowGroupInvites: true,
allowMentions: true,
allowAnalytics: true,
allowPersonalization: true,
allowThirdPartyIntegrations: false,
emailMarketing: false,
emailProductUpdates: true,
emailSecurityAlerts: true,
emailActivityDigest: true,
activityHistoryRetention: "90d",
searchHistoryEnabled: true,
twoFactorEnabled: false,
loginNotifications: true,
sessionTimeout: 60,
};
interface UserPrivacyProps {
initialSettings?: Record<string, unknown>;
}
export default function UserPrivacy({ initialSettings }: UserPrivacyProps) {
const [isPending, startTransition] = useTransition();
const [settings, setSettings] = useState<PrivacySettings>(() => {
if (!initialSettings) return defaultSettings;
return {
...defaultSettings,
...Object.fromEntries(
Object.entries(initialSettings).filter(([k]) => k in defaultSettings)
),
} as PrivacySettings;
});
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [activeSection, setActiveSection] = useState<string | null>(null);
const updateSetting = <K extends keyof PrivacySettings>(key: K, value: PrivacySettings[K]) => {
setSettings((prev) => ({ ...prev, [key]: value }));
};
const handleSave = () => {
startTransition(async () => {
try {
const { savePrivacySettings } = await import("@/app/actions/privacy");
const result = await savePrivacySettings(settings as unknown as Record<string, unknown>);
if (result.error) {
setMessage({ type: "error", text: result.error });
} else {
setMessage({ type: "success", text: result.message || "Privacy settings saved successfully." });
}
} catch {
setMessage({ type: "error", text: "Failed to save privacy settings." });
}
setTimeout(() => setMessage(null), 5000);
});
};
const handleExportData = () => {
setMessage({ type: "success", text: "Data export request submitted. You will receive an email when ready." });
setTimeout(() => setMessage(null), 5000);
};
const SettingSwitch = ({
label,
description,
checked,
onChange,
}: {
label: string;
description?: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) => (
<div className="d-flex justify-content-between align-items-start py-3 border-bottom">
<div className="me-3">
<div className="fw-medium">{label}</div>
{description && <small className="text-muted">{description}</small>}
</div>
<label className="form-check form-switch mb-0">
<input
type="checkbox"
className="form-check-input"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
</label>
</div>
);
const privacyScore = (() => {
let score = 50;
if (settings.profileVisibility === "private") score += 15;
else if (settings.profileVisibility === "contacts") score += 10;
if (!settings.showEmail) score += 5;
if (!settings.showPhone) score += 5;
if (settings.twoFactorEnabled) score += 15;
if (!settings.allowThirdPartyIntegrations) score += 5;
if (!settings.emailMarketing) score += 5;
return Math.min(score, 100);
})();
return (
<>
{/* Page Header */}
<div className="d-flex align-items-center justify-content-between mb-4">
<div className="d-flex align-items-center">
<Link href="/" className="btn btn-light btn-sm me-3">
<i className="ph-arrow-left"></i>
</Link>
<div>
<h4 className="mb-0">
<i className="ph-lock me-2"></i>
Privacy Settings
</h4>
<p className="text-muted mb-0 mt-1">Control your privacy, data sharing preferences, and communication settings.</p>
</div>
</div>
<button className="btn btn-primary" onClick={handleSave} disabled={isPending}>
{isPending ? (
<>
<span className="spinner-border spinner-border-sm me-2"></span>
Saving...
</>
) : (
<>
<i className="ph-floppy-disk me-2"></i>
Save Changes
</>
)}
</button>
</div>
{/* Alert */}
{message && (
<div
className={`alert ${message.type === "success" ? "alert-success" : "alert-danger"} alert-dismissible`}
role="alert"
>
<i className={`ph-${message.type === "success" ? "check-circle" : "warning"} me-2`}></i>
{message.text}
<button type="button" className="btn-close" onClick={() => setMessage(null)}></button>
</div>
)}
<div className="row">
{/* Left Column - Settings */}
<div className="col-xl-8">
{/* Profile Visibility */}
<div className="card">
<div
className="card-header d-flex align-items-center"
onClick={() => setActiveSection(activeSection === "profile" ? null : "profile")}
style={{ cursor: "pointer" }}
>
<i className="ph-user-circle me-2 text-primary"></i>
<h5 className="mb-0 flex-fill">Profile Visibility</h5>
<i className={`ph-caret-${activeSection === "profile" ? "up" : "down"}`}></i>
</div>
<div className={`card-body ${activeSection !== null && activeSection !== "profile" ? "d-none" : ""}`}>
<div className="mb-4">
<label className="form-label">Who can see your profile?</label>
<div className="d-flex gap-2 flex-wrap">
{[
{ value: "public", label: "Everyone", icon: "ph-globe", desc: "Visible to all users" },
{ value: "contacts", label: "Contacts Only", icon: "ph-users", desc: "Only your contacts" },
{ value: "private", label: "Private", icon: "ph-lock", desc: "Only you" },
].map((option) => (
<div
key={option.value}
className={`card flex-fill ${settings.profileVisibility === option.value ? "border-primary" : ""}`}
style={{ cursor: "pointer", minWidth: "150px" }}
onClick={() => updateSetting("profileVisibility", option.value as "public" | "contacts" | "private")}
>
<div className="card-body text-center py-3">
<i className={`${option.icon} fs-2 ${settings.profileVisibility === option.value ? "text-primary" : "text-muted"} mb-2 d-block`}></i>
<div className="fw-medium">{option.label}</div>
<small className="text-muted">{option.desc}</small>
</div>
</div>
))}
</div>
</div>
<SettingSwitch
label="Show email address"
description="Allow others to see your email address on your profile"
checked={settings.showEmail}
onChange={(v) => updateSetting("showEmail", v)}
/>
<SettingSwitch
label="Show phone number"
description="Allow others to see your phone number on your profile"
checked={settings.showPhone}
onChange={(v) => updateSetting("showPhone", v)}
/>
<SettingSwitch
label="Show location"
description="Display your location on your profile"
checked={settings.showLocation}
onChange={(v) => updateSetting("showLocation", v)}
/>
<SettingSwitch
label="Show online status"
description="Let others see when you're online"
checked={settings.showOnlineStatus}
onChange={(v) => updateSetting("showOnlineStatus", v)}
/>
</div>
</div>
{/* Communication */}
<div className="card">
<div
className="card-header d-flex align-items-center"
onClick={() => setActiveSection(activeSection === "communication" ? null : "communication")}
style={{ cursor: "pointer" }}
>
<i className="ph-chats me-2 text-success"></i>
<h5 className="mb-0 flex-fill">Communication</h5>
<i className={`ph-caret-${activeSection === "communication" ? "up" : "down"}`}></i>
</div>
<div className={`card-body ${activeSection !== null && activeSection !== "communication" ? "d-none" : ""}`}>
<SettingSwitch
label="Allow direct messages"
description="Let other users send you direct messages"
checked={settings.allowDirectMessages}
onChange={(v) => updateSetting("allowDirectMessages", v)}
/>
<SettingSwitch
label="Allow group invites"
description="Let others invite you to groups and channels"
checked={settings.allowGroupInvites}
onChange={(v) => updateSetting("allowGroupInvites", v)}
/>
<SettingSwitch
label="Allow @mentions"
description="Let others mention you in messages and comments"
checked={settings.allowMentions}
onChange={(v) => updateSetting("allowMentions", v)}
/>
</div>
</div>
{/* Data & Tracking */}
<div className="card">
<div
className="card-header d-flex align-items-center"
onClick={() => setActiveSection(activeSection === "data" ? null : "data")}
style={{ cursor: "pointer" }}
>
<i className="ph-chart-line me-2 text-info"></i>
<h5 className="mb-0 flex-fill">Data & Tracking</h5>
<i className={`ph-caret-${activeSection === "data" ? "up" : "down"}`}></i>
</div>
<div className={`card-body ${activeSection !== null && activeSection !== "data" ? "d-none" : ""}`}>
<SettingSwitch
label="Usage analytics"
description="Help us improve by sharing anonymous usage data"
checked={settings.allowAnalytics}
onChange={(v) => updateSetting("allowAnalytics", v)}
/>
<SettingSwitch
label="Personalization"
description="Allow personalized recommendations based on your activity"
checked={settings.allowPersonalization}
onChange={(v) => updateSetting("allowPersonalization", v)}
/>
<SettingSwitch
label="Third-party integrations"
description="Allow connected apps to access your data"
checked={settings.allowThirdPartyIntegrations}
onChange={(v) => updateSetting("allowThirdPartyIntegrations", v)}
/>
<div className="mt-4 pt-3 border-top">
<label className="form-label">Activity history retention</label>
<select
className="form-select"
value={settings.activityHistoryRetention}
onChange={(e) => updateSetting("activityHistoryRetention", e.target.value as "30d" | "90d" | "1y" | "forever")}
>
<option value="30d">30 days</option>
<option value="90d">90 days</option>
<option value="1y">1 year</option>
<option value="forever">Keep forever</option>
</select>
<small className="form-text text-muted">How long to keep your activity history</small>
</div>
<SettingSwitch
label="Search history"
description="Save your search history for better suggestions"
checked={settings.searchHistoryEnabled}
onChange={(v) => updateSetting("searchHistoryEnabled", v)}
/>
</div>
</div>
{/* Email Preferences */}
<div className="card">
<div
className="card-header d-flex align-items-center"
onClick={() => setActiveSection(activeSection === "email" ? null : "email")}
style={{ cursor: "pointer" }}
>
<i className="ph-envelope me-2 text-warning"></i>
<h5 className="mb-0 flex-fill">Email Preferences</h5>
<i className={`ph-caret-${activeSection === "email" ? "up" : "down"}`}></i>
</div>
<div className={`card-body ${activeSection !== null && activeSection !== "email" ? "d-none" : ""}`}>
<SettingSwitch
label="Marketing emails"
description="Receive promotional emails and special offers"
checked={settings.emailMarketing}
onChange={(v) => updateSetting("emailMarketing", v)}
/>
<SettingSwitch
label="Product updates"
description="Get notified about new features and improvements"
checked={settings.emailProductUpdates}
onChange={(v) => updateSetting("emailProductUpdates", v)}
/>
<SettingSwitch
label="Security alerts"
description="Important notifications about your account security"
checked={settings.emailSecurityAlerts}
onChange={(v) => updateSetting("emailSecurityAlerts", v)}
/>
<SettingSwitch
label="Activity digest"
description="Weekly summary of activity in your organization"
checked={settings.emailActivityDigest}
onChange={(v) => updateSetting("emailActivityDigest", v)}
/>
</div>
</div>
{/* Security & Login */}
<div className="card">
<div
className="card-header d-flex align-items-center"
onClick={() => setActiveSection(activeSection === "security" ? null : "security")}
style={{ cursor: "pointer" }}
>
<i className="ph-shield-check me-2 text-danger"></i>
<h5 className="mb-0 flex-fill">Security & Login</h5>
<i className={`ph-caret-${activeSection === "security" ? "up" : "down"}`}></i>
</div>
<div className={`card-body ${activeSection !== null && activeSection !== "security" ? "d-none" : ""}`}>
<SettingSwitch
label="Two-factor authentication"
description="Add an extra layer of security to your account"
checked={settings.twoFactorEnabled}
onChange={(v) => updateSetting("twoFactorEnabled", v)}
/>
<SettingSwitch
label="Login notifications"
description="Get notified when someone logs into your account"
checked={settings.loginNotifications}
onChange={(v) => updateSetting("loginNotifications", v)}
/>
<div className="mt-4 pt-3 border-top">
<label className="form-label">Session timeout</label>
<select
className="form-select"
value={settings.sessionTimeout}
onChange={(e) => updateSetting("sessionTimeout", parseInt(e.target.value))}
>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">1 hour</option>
<option value="120">2 hours</option>
<option value="480">8 hours</option>
<option value="1440">24 hours</option>
</select>
<small className="form-text text-muted">Automatically log out after inactivity</small>
</div>
</div>
</div>
</div>
{/* Right Column */}
<div className="col-xl-4">
{/* Privacy Score */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="ph-shield me-2"></i>
Privacy Score
</h5>
</div>
<div className="card-body text-center">
<div className="position-relative d-inline-block mb-3">
<div
className={`rounded-circle bg-${privacyScore >= 80 ? "success" : privacyScore >= 60 ? "warning" : "danger"} bg-opacity-10 d-flex align-items-center justify-content-center`}
style={{ width: "120px", height: "120px" }}
>
<div>
<h2 className={`mb-0 text-${privacyScore >= 80 ? "success" : privacyScore >= 60 ? "warning" : "danger"}`}>
{privacyScore}%
</h2>
<small className="text-muted">Score</small>
</div>
</div>
</div>
<p className="text-muted mb-0">
{privacyScore >= 80
? "Your privacy settings are well configured."
: "Consider tightening your privacy settings for better protection."}
</p>
</div>
</div>
{/* Data Management */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="ph-database me-2"></i>
Data Management
</h5>
</div>
<div className="card-body">
<p className="text-muted mb-3">Download or manage your personal data stored in our systems.</p>
<div className="d-grid gap-2">
<button className="btn btn-outline-primary" onClick={handleExportData}>
<i className="ph-download me-2"></i>
Export My Data
</button>
<button className="btn btn-outline-secondary">
<i className="ph-eye me-2"></i>
View Stored Data
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,339 @@
"use client";
import { useState } from "react";
import Link from "next/link";
interface UserProfileProps {
displayName: string;
givenName: string;
familyName: string;
email: string;
groups: string[];
keycloakId: string;
tenantId: string;
}
export default function UserProfile({
displayName,
givenName,
familyName,
email,
groups,
keycloakId,
tenantId,
}: UserProfileProps) {
const [activeTab, setActiveTab] = useState<"overview" | "groups" | "sessions">("overview");
const normalizedGroups = groups.map((g) => g.replace(/^\//, "")).filter(Boolean);
const adminGroups = normalizedGroups.filter(
(g) => g.includes("admin") || g === "platformadmins" || g === "platformoperators" || g === "tenantadmins"
);
const productGroups = normalizedGroups.filter((g) => g.startsWith("gsc") && !g.includes("admin"));
const otherGroups = normalizedGroups.filter(
(g) => !adminGroups.includes(g) && !productGroups.includes(g)
);
const initials = `${givenName.charAt(0)}${familyName.charAt(0)}`.toUpperCase();
return (
<>
{/* Page Header */}
<div className="d-flex align-items-center mb-4">
<Link href="/" className="btn btn-light btn-sm me-3">
<i className="ph-arrow-left"></i>
</Link>
<div>
<h4 className="mb-0">
<i className="ph-user-circle me-2"></i>
My Profile
</h4>
<p className="text-muted mb-0 mt-1">View your account information and group memberships.</p>
</div>
</div>
<div className="row">
{/* Left Column */}
<div className="col-xl-8">
{/* Profile Card */}
<div className="card mb-3">
<div className="card-body">
<div className="d-flex align-items-center">
<div
className="bg-primary bg-opacity-10 text-primary rounded-circle d-flex align-items-center justify-content-center me-4 flex-shrink-0"
style={{ width: "80px", height: "80px", fontSize: "1.5rem" }}
>
<span className="fw-bold">{initials}</span>
</div>
<div className="flex-fill">
<h3 className="mb-1">{displayName}</h3>
<p className="text-muted mb-2">{email}</p>
<div className="d-flex gap-2 flex-wrap">
{adminGroups.length > 0 && (
<span className="badge bg-danger bg-opacity-10 text-danger">
<i className="ph-shield-star me-1"></i>
Administrator
</span>
)}
<span className="badge bg-success bg-opacity-10 text-success">
<i className="ph-check-circle me-1"></i>
Active
</span>
<span className="badge bg-primary bg-opacity-10 text-primary">
<i className="ph-users me-1"></i>
{normalizedGroups.length} group{normalizedGroups.length !== 1 ? "s" : ""}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="card">
<div className="card-header">
<ul className="nav nav-tabs card-header-tabs">
<li className="nav-item">
<button
className={`nav-link ${activeTab === "overview" ? "active" : ""}`}
onClick={() => setActiveTab("overview")}
>
<i className="ph-identification-card me-2"></i>
Overview
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "groups" ? "active" : ""}`}
onClick={() => setActiveTab("groups")}
>
<i className="ph-users-three me-2"></i>
Groups
<span className="badge bg-primary ms-2">{normalizedGroups.length}</span>
</button>
</li>
<li className="nav-item">
<button
className={`nav-link ${activeTab === "sessions" ? "active" : ""}`}
onClick={() => setActiveTab("sessions")}
>
<i className="ph-devices me-2"></i>
Active Sessions
</button>
</li>
</ul>
</div>
<div className="card-body">
{/* Overview Tab */}
{activeTab === "overview" && (
<>
<h5 className="mb-3">
<i className="ph-identification-card me-2"></i>
Personal Information
</h5>
<div className="table-responsive">
<table className="table">
<tbody>
<tr>
<td className="text-muted" style={{ width: "200px" }}>First Name</td>
<td className="fw-medium">{givenName}</td>
</tr>
<tr>
<td className="text-muted">Last Name</td>
<td className="fw-medium">{familyName}</td>
</tr>
<tr>
<td className="text-muted">Display Name</td>
<td className="fw-medium">{displayName}</td>
</tr>
<tr>
<td className="text-muted">Email Address</td>
<td className="fw-medium">
<i className="ph-envelope me-1 text-primary"></i>
{email}
</td>
</tr>
<tr>
<td className="text-muted">Tenant ID</td>
<td>
<code className="text-muted">{tenantId}</code>
</td>
</tr>
<tr>
<td className="text-muted">Account ID</td>
<td>
<code className="text-muted">{keycloakId}</code>
</td>
</tr>
</tbody>
</table>
</div>
<div className="alert alert-light border mt-3 mb-0">
<i className="ph-info me-2 text-primary"></i>
Profile information is managed through your identity provider. Contact your administrator to update your name or email.
</div>
</>
)}
{/* Groups Tab */}
{activeTab === "groups" && (
<>
{adminGroups.length > 0 && (
<>
<h5 className="mb-3">
<i className="ph-shield-star me-2 text-danger"></i>
Administrative Roles
</h5>
<div className="d-flex gap-2 flex-wrap mb-4">
{adminGroups.map((group) => (
<span key={group} className="badge bg-danger bg-opacity-10 text-danger py-2 px-3">
<i className="ph-shield me-1"></i>
{group}
</span>
))}
</div>
</>
)}
{productGroups.length > 0 && (
<>
<h5 className="mb-3">
<i className="ph-app-window me-2 text-primary"></i>
Product Access
</h5>
<div className="d-flex gap-2 flex-wrap mb-4">
{productGroups.map((group) => (
<span key={group} className="badge bg-primary bg-opacity-10 text-primary py-2 px-3">
<i className="ph-app-window me-1"></i>
{group}
</span>
))}
</div>
</>
)}
{otherGroups.length > 0 && (
<>
<h5 className="mb-3">
<i className="ph-users me-2 text-secondary"></i>
Other Groups
</h5>
<div className="d-flex gap-2 flex-wrap mb-4">
{otherGroups.map((group) => (
<span key={group} className="badge bg-secondary bg-opacity-10 text-secondary py-2 px-3">
{group}
</span>
))}
</div>
</>
)}
{normalizedGroups.length === 0 && (
<div className="text-center text-muted py-4">
<i className="ph-users-three fs-1 d-block mb-2"></i>
<p>No group memberships found.</p>
</div>
)}
</>
)}
{/* Sessions Tab */}
{activeTab === "sessions" && (
<>
<h5 className="mb-3">
<i className="ph-devices me-2"></i>
Active Sessions
</h5>
<div className="d-flex align-items-center p-3 bg-light rounded mb-3">
<div className="bg-success bg-opacity-10 text-success rounded-pill p-2 me-3">
<i className="ph-monitor"></i>
</div>
<div className="flex-fill">
<div className="fw-medium">Current Session</div>
<small className="text-muted">Active now</small>
</div>
<span className="badge bg-success">
<i className="ph-check me-1"></i>
Current
</span>
</div>
<div className="alert alert-light border mb-0">
<i className="ph-info me-2 text-primary"></i>
Session management is handled by your identity provider. Use the security page to review login activity.
</div>
</>
)}
</div>
</div>
</div>
{/* Right Column */}
<div className="col-xl-4">
{/* Quick Info Card */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="ph-info me-2"></i>
Account Summary
</h5>
</div>
<div className="card-body">
<div className="list-group list-group-flush">
<div className="list-group-item d-flex justify-content-between px-0">
<span className="text-muted">Status</span>
<span className="badge bg-success">Active</span>
</div>
<div className="list-group-item d-flex justify-content-between px-0">
<span className="text-muted">Role</span>
<span className="fw-medium">
{adminGroups.length > 0 ? "Administrator" : "User"}
</span>
</div>
<div className="list-group-item d-flex justify-content-between px-0">
<span className="text-muted">Groups</span>
<span className="fw-medium">{normalizedGroups.length}</span>
</div>
<div className="list-group-item d-flex justify-content-between px-0">
<span className="text-muted">Products</span>
<span className="fw-medium">{productGroups.length + (adminGroups.length > 0 ? adminGroups.filter(g => g.startsWith("gsc")).length : 0)}</span>
</div>
</div>
</div>
</div>
{/* Quick Actions Card */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">
<i className="ph-lightning me-2"></i>
Quick Actions
</h5>
</div>
<div className="card-body">
<div className="d-grid gap-2">
<Link href="/account/settings" className="btn btn-light text-start">
<i className="ph-gear me-2"></i>
Account Settings
</Link>
<Link href="/account/security" className="btn btn-light text-start">
<i className="ph-shield-check me-2"></i>
Security Settings
</Link>
<Link href="/account/privacy" className="btn btn-light text-start">
<i className="ph-lock-key me-2"></i>
Privacy Settings
</Link>
<Link href="/account/analytics" className="btn btn-light text-start">
<i className="ph-chart-line-up me-2"></i>
View Analytics
</Link>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,428 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
interface Extension {
id: string;
extension: string;
name: string;
mohClassId: string | null;
}
interface FollowMe {
id: string;
isEnabled: boolean;
ringTime: number;
numbers: Array<{
id: string;
number: string;
ringDelay: number;
orderNum: number;
}>;
}
interface VoicemailBox {
id: string;
mailbox: string;
email: string | null;
emailNotify: boolean;
attachVoicemail: boolean;
newMessages: number;
}
interface MohClass {
id: string;
name: string;
isDefault: boolean;
}
interface UserVoiceSettingsProps {
extension?: Extension | null;
followMe?: FollowMe | null;
voicemailBox?: VoicemailBox | null;
mohClasses?: MohClass[];
tenantId: string;
userEmail?: string;
}
export default function UserVoiceSettings({
extension = null,
followMe = null,
voicemailBox = null,
mohClasses = [],
tenantId,
userEmail,
}: UserVoiceSettingsProps) {
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [activeSection, setActiveSection] = useState<string | null>("forwarding");
const handleSave = async (section: string, formData: FormData) => {
startTransition(async () => {
try {
const voice = await import("@/app/actions/voice");
let result;
if (section === "Call forwarding") {
result = await voice.updateUserCallForwarding(formData);
} else if (section === "Music on hold") {
result = await voice.updateUserMoh(formData);
} else if (section === "Voicemail") {
result = await voice.updateUserVoicemail(formData);
}
if (result?.error) {
setMessage({ type: "error", text: result.error });
} else {
setMessage({ type: "success", text: result?.message || `${section} settings saved successfully.` });
}
} catch {
setMessage({ type: "error", text: `Failed to save ${section} settings.` });
}
setTimeout(() => setMessage(null), 5000);
});
};
const toggleSection = (section: string) => {
setActiveSection(activeSection === section ? null : section);
};
if (!extension) {
return (
<>
<div className="d-flex align-items-center mb-4">
<Link href="/" className="btn btn-light btn-sm me-3">
<i className="ph-arrow-left"></i>
</Link>
<div>
<h4 className="mb-0">
<i className="ph-phone me-2"></i>
Voice Settings
</h4>
<p className="text-muted mb-0 mt-1">Manage your voice and phone settings</p>
</div>
</div>
<div className="alert alert-info">
<i className="ph-info me-2"></i>
No phone extension is linked to your account. Please contact your administrator to set up your phone extension.
</div>
</>
);
}
return (
<>
{/* Page Header */}
<div className="d-flex align-items-center mb-4">
<Link href="/" className="btn btn-light btn-sm me-3">
<i className="ph-arrow-left"></i>
</Link>
<div>
<h4 className="mb-0">
<i className="ph-phone me-2"></i>
Voice Settings
</h4>
<p className="text-muted mb-0 mt-1">
Manage your phone settings for extension <strong>{extension.extension}</strong>
</p>
</div>
</div>
{/* Alert Messages */}
{message && (
<div className={`alert alert-${message.type === "success" ? "success" : "danger"} alert-dismissible`} role="alert">
<i className={`ph-${message.type === "success" ? "check-circle" : "warning-circle"} me-2`}></i>
{message.text}
<button type="button" className="btn-close" onClick={() => setMessage(null)}></button>
</div>
)}
<div className="row">
<div className="col-xl-8">
{/* Call Forwarding Section */}
<div className="card mb-3">
<div
className="card-header d-flex align-items-center"
onClick={() => toggleSection("forwarding")}
style={{ cursor: "pointer" }}
>
<i className="ph-phone-forwarded fs-4 me-3 text-primary"></i>
<div className="flex-fill">
<h5 className="mb-0">Call Forwarding</h5>
<small className="text-muted">Forward calls when you&apos;re unavailable</small>
</div>
<i className={`ph-caret-${activeSection === "forwarding" ? "up" : "down"} fs-4`}></i>
</div>
<div className={`card-body ${activeSection !== "forwarding" ? "d-none" : ""}`}>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
formData.append("extensionId", extension.id);
formData.append("tenantId", tenantId);
handleSave("Call forwarding", formData);
}}
>
<div className="mb-3">
<div className="form-check form-switch">
<input
type="checkbox"
className="form-check-input"
name="forwardingEnabled"
defaultChecked={followMe?.isEnabled ?? false}
/>
<label className="form-check-label">Enable Call Forwarding</label>
</div>
<small className="text-muted">Forward unanswered calls to external numbers</small>
</div>
<div className="mb-3">
<label className="form-label">Ring Extension First (seconds)</label>
<input
type="number"
className="form-control"
name="ringTime"
defaultValue={followMe?.ringTime ?? 20}
min={5}
max={120}
style={{ maxWidth: "150px" }}
/>
</div>
<div className="mb-3">
<label className="form-label">Forward to Numbers</label>
{(followMe?.numbers || []).map((num, idx) => (
<div key={num.id} className="input-group mb-2" style={{ maxWidth: "400px" }}>
<span className="input-group-text">{idx + 1}</span>
<input
type="text"
className="form-control"
name={`forwardNumber_${idx}`}
defaultValue={num.number}
placeholder="Phone number"
/>
</div>
))}
{(!followMe?.numbers || followMe.numbers.length === 0) && (
<>
<div className="input-group mb-2" style={{ maxWidth: "400px" }}>
<span className="input-group-text">1</span>
<input type="text" className="form-control" name="forwardNumber_0" placeholder="Phone number" />
</div>
<div className="input-group mb-2" style={{ maxWidth: "400px" }}>
<span className="input-group-text">2</span>
<input type="text" className="form-control" name="forwardNumber_1" placeholder="Phone number (optional)" />
</div>
</>
)}
<small className="text-muted">Numbers will be tried in order if not answered</small>
</div>
<button type="submit" className="btn btn-primary" disabled={isPending}>
{isPending ? (
<><span className="spinner-border spinner-border-sm me-2"></span>Saving...</>
) : (
<><i className="ph-floppy-disk me-2"></i>Save Forwarding Settings</>
)}
</button>
</form>
</div>
</div>
{/* Music on Hold Section */}
<div className="card mb-3">
<div
className="card-header d-flex align-items-center"
onClick={() => toggleSection("moh")}
style={{ cursor: "pointer" }}
>
<i className="ph-music-notes fs-4 me-3 text-primary"></i>
<div className="flex-fill">
<h5 className="mb-0">Music on Hold</h5>
<small className="text-muted">Music played when you place callers on hold</small>
</div>
<i className={`ph-caret-${activeSection === "moh" ? "up" : "down"} fs-4`}></i>
</div>
<div className={`card-body ${activeSection !== "moh" ? "d-none" : ""}`}>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
formData.append("extensionId", extension.id);
formData.append("tenantId", tenantId);
handleSave("Music on hold", formData);
}}
>
<div className="mb-3">
<label className="form-label">Music on Hold Class</label>
<select
className="form-select"
name="mohClassId"
defaultValue={extension.mohClassId || ""}
style={{ maxWidth: "300px" }}
>
<option value="">Use Default</option>
{mohClasses.map((moh) => (
<option key={moh.id} value={moh.id}>
{moh.name} {moh.isDefault && "(Default)"}
</option>
))}
</select>
<small className="text-muted d-block mt-1">
Select the music or audio played to callers when you put them on hold
</small>
</div>
<button type="submit" className="btn btn-primary" disabled={isPending}>
{isPending ? (
<><span className="spinner-border spinner-border-sm me-2"></span>Saving...</>
) : (
<><i className="ph-floppy-disk me-2"></i>Save Music on Hold</>
)}
</button>
</form>
</div>
</div>
{/* Voicemail Section */}
<div className="card mb-3">
<div
className="card-header d-flex align-items-center"
onClick={() => toggleSection("voicemail")}
style={{ cursor: "pointer" }}
>
<i className="ph-voicemail fs-4 me-3 text-primary"></i>
<div className="flex-fill">
<h5 className="mb-0">Voicemail & Greetings</h5>
<small className="text-muted">Manage your voicemail settings and greetings</small>
</div>
<i className={`ph-caret-${activeSection === "voicemail" ? "up" : "down"} fs-4`}></i>
</div>
<div className={`card-body ${activeSection !== "voicemail" ? "d-none" : ""}`}>
{!voicemailBox ? (
<div className="alert alert-info mb-0">
<i className="ph-info me-2"></i>
No voicemail box is configured for your extension. Please contact your administrator.
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
formData.append("voicemailBoxId", voicemailBox.id);
formData.append("tenantId", tenantId);
handleSave("Voicemail", formData);
}}
>
<div className="row">
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">Voicemail Mailbox</label>
<input type="text" className="form-control" value={voicemailBox.mailbox} disabled />
</div>
<div className="mb-3">
<label className="form-label">Email for Notifications</label>
<input
type="email"
className="form-control"
name="email"
defaultValue={voicemailBox.email || ""}
placeholder={userEmail}
/>
<small className="text-muted">Receive voicemail notifications at this email</small>
</div>
<div className="mb-3">
<label className="form-label">Change PIN</label>
<input
type="password"
className="form-control"
name="pin"
placeholder="Enter new PIN"
minLength={4}
maxLength={8}
style={{ maxWidth: "200px" }}
/>
<small className="text-muted">4-8 digits. Leave blank to keep current PIN.</small>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">Notification Options</label>
<div className="form-check mb-2">
<input
type="checkbox"
className="form-check-input"
name="emailNotify"
defaultChecked={voicemailBox.emailNotify}
/>
<label className="form-check-label">Send email when new voicemail arrives</label>
</div>
<div className="form-check mb-2">
<input
type="checkbox"
className="form-check-input"
name="attachVoicemail"
defaultChecked={voicemailBox.attachVoicemail}
/>
<label className="form-check-label">Attach voicemail audio to email</label>
</div>
</div>
<div className="mb-3">
<label className="form-label">Messages</label>
<div className="alert alert-secondary py-2 mb-0">
<i className="ph-envelope me-2"></i>
You have <strong>{voicemailBox.newMessages}</strong> new message(s)
</div>
</div>
</div>
</div>
<button type="submit" className="btn btn-primary" disabled={isPending}>
{isPending ? (
<><span className="spinner-border spinner-border-sm me-2"></span>Saving...</>
) : (
<><i className="ph-floppy-disk me-2"></i>Save Voicemail Settings</>
)}
</button>
</form>
)}
</div>
</div>
</div>
<div className="col-xl-4">
{/* Extension Info Card */}
<div className="card">
<div className="card-header">
<h5 className="card-title mb-0">Your Extension</h5>
</div>
<div className="card-body">
<div className="d-flex align-items-center mb-3">
<span className="avatar avatar-lg bg-primary-lt me-3">
<i className="ph-phone fs-1"></i>
</span>
<div>
<h3 className="mb-0">{extension.extension}</h3>
<small className="text-muted">{extension.name}</small>
</div>
</div>
<hr />
<div className="text-muted">
<p className="mb-2">
<i className="ph-phone me-2"></i>
Dial <strong>{extension.extension}</strong> to reach you
</p>
{voicemailBox && (
<p className="mb-0">
<i className="ph-voicemail me-2"></i>
Voicemail box: <strong>{voicemailBox.mailbox}</strong>
</p>
)}
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,221 @@
"use client";
// Client-side container for the /access page: loads eligible/active/
// pending lists in parallel, hosts the request modal, handles revoke.
import { useCallback, useEffect, useState } from "react";
import RequestModal, { type EligibleRow } from "./RequestModal";
type ActiveGrant = {
id: string;
roleName: string;
grantedAt: string;
expiresAt: string;
justification: string;
};
type PendingGrant = {
id: string;
roleName: string;
requestedAt: string;
expiresAt: string;
justification: string;
};
function fmtRemaining(expiresAt: string): string {
const diffMs = new Date(expiresAt).getTime() - Date.now();
if (diffMs <= 0) return "expired";
const h = Math.floor(diffMs / 3_600_000);
const m = Math.floor((diffMs % 3_600_000) / 60_000);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export default function AccessPageClient({ needRole = null }: { needRole?: string | null }) {
const [eligible, setEligible] = useState<EligibleRow[]>([]);
const [active, setActive] = useState<ActiveGrant[]>([]);
const [pending, setPending] = useState<PendingGrant[]>([]);
const [loading, setLoading] = useState(true);
const [modalRow, setModalRow] = useState<EligibleRow | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [needAutoOpened, setNeedAutoOpened] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const [e, a] = await Promise.all([
fetch("/api/pam/eligible", { cache: "no-store" }).then((r) => r.json()),
fetch("/api/pam/active", { cache: "no-store" }).then((r) => r.json()),
]);
setEligible(e.eligible ?? []);
setActive(a.active ?? []);
// pending grants aren't a dedicated endpoint — pull via audit
// for now (low-volume). Mirror via /api/pam/active expansion
// in a future iteration.
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const t = setInterval(refresh, 30_000);
return () => clearInterval(t);
}, [refresh]);
// Auto-open the request modal for the role the caller asked for
// (via /access?need=<role>), but only once and only if the user is
// actually eligible.
useEffect(() => {
if (!needRole || needAutoOpened || eligible.length === 0) return;
const row = eligible.find((r) => r.role === needRole && r.policy && !r.currentlyActive);
if (row) {
setModalRow(row);
setModalOpen(true);
setNeedAutoOpened(true);
}
}, [needRole, needAutoOpened, eligible]);
const revoke = async (id: string) => {
setActive((g) => g.filter((x) => x.id !== id));
await fetch(`/api/pam/revoke/${encodeURIComponent(id)}`, { method: "POST" });
void refresh();
};
return (
<div className="container py-4">
<h2 className="mb-4">
<i className="ph ph-key me-2" aria-hidden="true"></i>
Privileged Access
</h2>
<p className="text-muted mb-4">
Request just-in-time access to administrative roles. Grants expire
automatically and don&apos;t require you to sign in again.
</p>
{needRole && (
<div className="alert alert-info py-2 mb-4 small">
<i className="ph ph-info me-1" aria-hidden="true"></i>
The page you tried to open requires <code>{needRole}</code>. Request
elevation below, then re-navigate.
</div>
)}
{/* ACTIVE */}
<h5 className="mt-4 mb-2">Active</h5>
{active.length === 0 ? (
<div className="text-muted small mb-4">No active grants.</div>
) : (
<div className="table-responsive mb-4">
<table className="table table-sm align-middle">
<thead>
<tr>
<th>Role</th>
<th>Granted</th>
<th>Expires</th>
<th>Remaining</th>
<th></th>
</tr>
</thead>
<tbody>
{active.map((g) => (
<tr key={g.id}>
<td><code>{g.roleName}</code></td>
<td className="small text-muted">{new Date(g.grantedAt).toLocaleString()}</td>
<td className="small text-muted">{new Date(g.expiresAt).toLocaleString()}</td>
<td><span className="badge bg-success-subtle text-success-emphasis">{fmtRemaining(g.expiresAt)}</span></td>
<td className="text-end">
<button
type="button"
className="btn btn-sm btn-outline-danger"
onClick={() => revoke(g.id)}
>
<i className="ph ph-x me-1"></i>Revoke
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ELIGIBLE */}
<h5 className="mt-4 mb-2">Eligible</h5>
{loading && eligible.length === 0 ? (
<div className="text-muted small">Loading</div>
) : eligible.length === 0 ? (
<div className="text-muted small">
You aren&apos;t a member of any <code>*_eligible</code> group. Ask an administrator
to add you to one if you need elevated access.
</div>
) : (
<div className="table-responsive">
<table className="table table-sm align-middle">
<thead>
<tr>
<th>Role</th>
<th>Approval</th>
<th>MFA</th>
<th>Max duration</th>
<th></th>
</tr>
</thead>
<tbody>
{eligible.map((row) => (
<tr key={row.role}>
<td>
<code>{row.role}</code>
{row.policy?.description && (
<div className="small text-muted">{row.policy.description}</div>
)}
</td>
<td>
{row.policy ? (
<span className={`badge ${row.policy.approvalMode === "audit" ? "bg-success-subtle text-success-emphasis" : "bg-info-subtle text-info-emphasis"}`}>
{row.policy.approvalMode}
</span>
) : (
<span className="badge bg-secondary-subtle text-secondary-emphasis">no policy</span>
)}
</td>
<td>
{row.policy?.requiresMfa ? (
<i className="ph ph-shield-check text-warning" title="MFA required"></i>
) : (
<span className="text-muted small"></span>
)}
</td>
<td className="small text-muted">
{row.policy ? `${row.policy.maxDurationHours}h` : "—"}
</td>
<td className="text-end">
<button
type="button"
className="btn btn-sm btn-primary"
disabled={!row.policy || row.currentlyActive}
onClick={() => {
setModalRow(row);
setModalOpen(true);
}}
>
{row.currentlyActive ? "Already active" : "Request"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<RequestModal
row={modalRow}
open={modalOpen}
onClose={() => setModalOpen(false)}
onSuccess={refresh}
/>
</div>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
// Top-bar widget — renders active grants as chips with a revoke
// button, and a "Request access" link to /access. Polls every 30s so
// the countdown stays roughly fresh and the chip disappears when a
// grant expires server-side. Optimistic on revoke.
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
type Grant = {
id: string;
roleName: string;
grantedAt: string;
expiresAt: string;
};
function fmtRemaining(expiresAt: string): string {
const diffMs = new Date(expiresAt).getTime() - Date.now();
if (diffMs <= 0) return "expired";
const h = Math.floor(diffMs / 3_600_000);
const m = Math.floor((diffMs % 3_600_000) / 60_000);
if (h > 0) return `${h}h${m.toString().padStart(2, "0")}`;
return `${m}m`;
}
export default function ActiveGrantsWidget() {
const [grants, setGrants] = useState<Grant[]>([]);
const [, setTick] = useState(0); // forces re-render for countdown
const load = useCallback(async () => {
try {
const r = await fetch("/api/pam/active", { cache: "no-store" });
if (!r.ok) return;
const j = await r.json();
setGrants(j.active ?? []);
} catch {
/* network blip — try again on next tick */
}
}, []);
useEffect(() => {
void load();
const refresh = setInterval(load, 30_000);
const countdown = setInterval(() => setTick((n) => n + 1), 30_000);
return () => {
clearInterval(refresh);
clearInterval(countdown);
};
}, [load]);
const revoke = async (id: string) => {
setGrants((g) => g.filter((x) => x.id !== id)); // optimistic
try {
await fetch(`/api/pam/revoke/${encodeURIComponent(id)}`, { method: "POST" });
} finally {
void load();
}
};
return (
<div className="d-flex align-items-center gap-2">
{grants.map((g) => (
<span
key={g.id}
className="badge bg-warning-subtle text-warning-emphasis d-inline-flex align-items-center gap-1"
title={`Active until ${new Date(g.expiresAt).toLocaleString()}`}
style={{ paddingLeft: 8 }}
>
<i className="ph ph-shield-check" aria-hidden="true"></i>
<span style={{ fontFamily: "var(--bs-font-monospace, monospace)" }}>{g.roleName}</span>
<span className="text-muted small">·</span>
<span className="small">{fmtRemaining(g.expiresAt)}</span>
<button
type="button"
className="btn btn-sm btn-link p-0 ms-1 text-warning-emphasis"
aria-label="Revoke"
onClick={() => revoke(g.id)}
title="Revoke now"
>
<i className="ph ph-x"></i>
</button>
</span>
))}
<Link href="/access" className="btn btn-sm btn-outline-secondary">
<i className="ph ph-key" aria-hidden="true"></i>
<span className="ms-1 d-none d-md-inline">Access</span>
</Link>
</div>
);
}

View File

@@ -0,0 +1,194 @@
"use client";
// Request-access modal. Renders inside /access page; controlled by
// parent. Submits to POST /api/pam/request and refreshes the parent
// on success.
import { useEffect, useState } from "react";
export type EligibleRow = {
role: string;
policy: {
approvalMode: "audit" | "manual";
requiresMfa: boolean;
maxDurationHours: number;
defaultDurationHours: number;
description?: string | null;
} | null;
currentlyActive: boolean;
};
export default function RequestModal({
row,
open,
onClose,
onSuccess,
}: {
row: EligibleRow | null;
open: boolean;
onClose: () => void;
onSuccess: () => void;
}) {
const [hours, setHours] = useState(1);
const [justification, setJustification] = useState("");
const [mfaToken, setMfaToken] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !row) return;
setHours(row.policy?.defaultDurationHours ?? 1);
setJustification("");
setMfaToken("");
setError(null);
}, [open, row]);
if (!open || !row) return null;
const policy = row.policy;
const canSubmit =
!submitting &&
justification.trim().length >= 20 &&
hours > 0 &&
(!policy?.requiresMfa || /^\d{6}$/.test(mfaToken));
const submit = async () => {
if (!policy) return;
setSubmitting(true);
setError(null);
try {
const r = await fetch("/api/pam/request", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
role: row.role,
durationHours: hours,
justification: justification.trim(),
mfaToken: policy.requiresMfa ? mfaToken : undefined,
}),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) {
setError(j.error ? `${j.error}${j.detail ? `: ${j.detail}` : ""}` : `HTTP ${r.status}`);
return;
}
onSuccess();
onClose();
} catch (err) {
setError(String(err));
} finally {
setSubmitting(false);
}
};
return (
<div
className="modal show d-block"
tabIndex={-1}
style={{ background: "rgba(0,0,0,.4)" }}
role="dialog"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="modal-dialog modal-dialog-centered" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
Request <code>{row.role}</code>
</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
</div>
<div className="modal-body">
{policy ? (
<>
<div className="mb-3 small text-muted">
{policy.description}
<div className="mt-1">
Approval mode:{" "}
<span className={`badge ${policy.approvalMode === "audit" ? "bg-success-subtle text-success-emphasis" : "bg-info-subtle text-info-emphasis"}`}>
{policy.approvalMode}
</span>{" "}
{policy.requiresMfa && (
<span className="badge bg-warning-subtle text-warning-emphasis ms-1">
MFA required
</span>
)}
</div>
</div>
<div className="mb-3">
<label htmlFor="pam-duration" className="form-label">
Duration: <strong>{hours}h</strong> (max {policy.maxDurationHours}h)
</label>
<input
id="pam-duration"
type="range"
className="form-range"
min={1}
max={policy.maxDurationHours}
value={hours}
onChange={(e) => setHours(parseInt(e.target.value, 10))}
/>
</div>
<div className="mb-3">
<label htmlFor="pam-justification" className="form-label">
Justification <span className="text-muted small">(min 20 chars)</span>
</label>
<textarea
id="pam-justification"
className="form-control"
rows={3}
maxLength={1000}
value={justification}
onChange={(e) => setJustification(e.target.value)}
placeholder="Why do you need this access right now?"
/>
</div>
{policy.requiresMfa && (
<div className="mb-3">
<label htmlFor="pam-mfa" className="form-label">
One-time code <span className="text-muted small">(6 digits)</span>
</label>
<input
id="pam-mfa"
type="text"
inputMode="numeric"
pattern="\d{6}"
maxLength={6}
className="form-control"
value={mfaToken}
onChange={(e) => setMfaToken(e.target.value.replace(/\D/g, ""))}
/>
</div>
)}
{error && (
<div className="alert alert-danger py-2 mb-0 small">{error}</div>
)}
</>
) : (
<div className="alert alert-warning py-2 mb-0 small">
No policy is configured for <code>{row.role}</code>. Ask an administrator
to add one before requesting this role.
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
disabled={!canSubmit}
onClick={submit}
>
{submitting ? "Requesting…" : policy?.approvalMode === "audit" ? "Request & activate" : "Send for approval"}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,868 @@
"use client";
import { useState, useId, useRef, useEffect } from "react";
import type {
AgentConfig,
OceanTraits,
PersonaConfig,
GuardrailsConfig,
MemorySettings,
MbtiType,
PersonaStatus,
} from "@gsc/chat";
interface AgentSettingsFormProps {
initialConfig: AgentConfig;
userGivenName?: string;
}
// ── Help Tooltip ─────────────────────────────────────────────────────────────
function HelpTip({ text }: { text: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<span ref={ref} className="position-relative d-inline-block ms-1" style={{ verticalAlign: "middle" }}>
<button
type="button"
className="btn btn-link p-0 border-0 text-muted"
style={{ fontSize: "0.75rem", lineHeight: 1, width: 16, height: 16 }}
onClick={() => setOpen((v) => !v)}
aria-label="Help"
>
<i className="ph-question"></i>
</button>
{open && (
<span
className="position-absolute bg-dark text-white small rounded px-2 py-1"
style={{
bottom: "calc(100% + 6px)",
left: "50%",
transform: "translateX(-50%)",
whiteSpace: "normal",
width: 260,
zIndex: 1080,
fontSize: "0.8rem",
lineHeight: 1.4,
boxShadow: "0 2px 8px rgba(0,0,0,.25)",
}}
>
{text}
<span
style={{
position: "absolute",
top: "100%",
left: "50%",
transform: "translateX(-50%)",
borderWidth: 5,
borderStyle: "solid",
borderColor: "var(--bs-dark) transparent transparent transparent",
}}
/>
</span>
)}
</span>
);
}
// ── Constants ───────────────────────────────────────────────────────────────
const MBTI_DESCRIPTIONS: Record<MbtiType, string> = {
INTJ: "Architect — Strategic, independent, logical planner",
INTP: "Logician — Analytical, inventive, deep thinker",
ENTJ: "Commander — Bold, decisive, natural leader",
ENTP: "Debater — Quick-witted, resourceful, challenger",
INFJ: "Advocate — Insightful, principled, compassionate",
INFP: "Mediator — Idealistic, empathetic, creative",
ENFJ: "Protagonist — Charismatic, inspiring, supportive",
ENFP: "Campaigner — Enthusiastic, imaginative, sociable",
ISTJ: "Logistician — Responsible, thorough, dependable",
ISFJ: "Defender — Warm, dedicated, protective",
ESTJ: "Executive — Organized, direct, strong-willed",
ESFJ: "Consul — Caring, sociable, tradition-minded",
ISTP: "Virtuoso — Practical, observant, hands-on",
ISFP: "Adventurer — Gentle, sensitive, open-minded",
ESTP: "Entrepreneur — Energetic, perceptive, action-oriented",
ESFP: "Entertainer — Spontaneous, playful, encouraging",
};
const MBTI_TYPES = Object.keys(MBTI_DESCRIPTIONS) as MbtiType[];
const ARCHETYPE_DESCRIPTIONS: Record<string, string> = {
"The Mentor": "Guides with wisdom and experience, encourages growth",
"The Helper": "Eager to assist, anticipates needs, service-oriented",
"The Expert": "Deep domain knowledge, authoritative, detail-focused",
"The Companion": "Friendly and relatable, builds rapport, conversational",
"The Challenger": "Pushes thinking, asks tough questions, growth-driven",
"The Creator": "Innovative and imaginative, generates novel ideas",
"The Caregiver": "Nurturing and empathetic, prioritizes well-being",
"The Sage": "Reflective and philosophical, seeks deeper meaning",
"The Hero": "Action-oriented, tackles problems head-on, confident",
"The Rebel": "Unconventional, challenges the status quo, bold",
"The Jester": "Lighthearted and witty, uses humor to engage",
"The Explorer": "Curious and adventurous, discovers new possibilities",
};
const ARCHETYPE_SUGGESTIONS = Object.keys(ARCHETYPE_DESCRIPTIONS);
const VOICE_TONE_SUGGESTIONS = [
"Professional and concise",
"Warm and encouraging",
"Formal and authoritative",
"Casual and friendly",
"Technical and precise",
"Empathetic and supportive",
"Witty and conversational",
"Calm and reassuring",
"Direct and no-nonsense",
"Enthusiastic and motivating",
];
const AVAILABLE_MODELS = [
{ value: "gpt-4o", label: "GPT-4o" },
{ value: "gpt-4o-mini", label: "GPT-4o Mini" },
{ value: "claude-3-5-sonnet", label: "Claude 3.5 Sonnet" },
{ value: "claude-3-5-haiku", label: "Claude 3.5 Haiku" },
{ value: "claude-3-opus", label: "Claude 3 Opus" },
];
const OCEAN_TRAITS = [
{ key: "openness" as const, label: "Openness", desc: "Curiosity, creativity, and openness to new experiences", low: "Practical", high: "Creative", color: "primary" },
{ key: "conscientiousness" as const, label: "Conscientiousness", desc: "Organization, dependability, and self-discipline", low: "Flexible", high: "Organized", color: "success" },
{ key: "extraversion" as const, label: "Extraversion", desc: "Sociability, assertiveness, and positive emotions", low: "Reserved", high: "Outgoing", color: "warning" },
{ key: "agreeableness" as const, label: "Agreeableness", desc: "Cooperation, trust, and helpfulness", low: "Challenging", high: "Cooperative", color: "info" },
{ key: "neuroticism" as const, label: "Neuroticism", desc: "Emotional instability and tendency toward negative emotions", low: "Stable", high: "Sensitive", color: "danger" },
];
const OCEAN_PRESETS: Record<string, { name: string; values: OceanTraits }> = {
balanced: { name: "Balanced", values: { openness: 50, conscientiousness: 50, extraversion: 50, agreeableness: 50, neuroticism: 50 } },
analytical: { name: "Analytical", values: { openness: 70, conscientiousness: 80, extraversion: 30, agreeableness: 50, neuroticism: 30 } },
creative: { name: "Creative", values: { openness: 90, conscientiousness: 40, extraversion: 60, agreeableness: 60, neuroticism: 50 } },
supportive: { name: "Supportive", values: { openness: 60, conscientiousness: 70, extraversion: 50, agreeableness: 90, neuroticism: 30 } },
assertive: { name: "Assertive", values: { openness: 60, conscientiousness: 70, extraversion: 80, agreeableness: 40, neuroticism: 30 } },
cautious: { name: "Cautious", values: { openness: 40, conscientiousness: 80, extraversion: 30, agreeableness: 60, neuroticism: 60 } },
};
// ── Component ───────────────────────────────────────────────────────────────
export function AgentSettingsForm({ initialConfig, userGivenName }: AgentSettingsFormProps) {
const baseId = useId();
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState("basic");
// Resolve active persona
const resolvePersona = (id: string) =>
initialConfig.personas.find((p) => p.id === id) || initialConfig.personas[0];
const [selectedPersonaId, setSelectedPersonaId] = useState(initialConfig.activePersonaId);
const initial = resolvePersona(initialConfig.activePersonaId);
// Agent-level settings
const [agentName, setAgentName] = useState(initialConfig.agentName || "");
const [userName, setUserName] = useState(initialConfig.userName || userGivenName || "");
// Basic Info
const [name, setName] = useState(initial.name);
const [archetype, setArchetype] = useState(initial.archetype || "");
const [isCustomArchetype, setIsCustomArchetype] = useState(
!!(initial.archetype && !ARCHETYPE_SUGGESTIONS.includes(initial.archetype))
);
const [voiceTone, setVoiceTone] = useState(initial.voiceTone || "");
const [isCustomVoiceTone, setIsCustomVoiceTone] = useState(
!!(initial.voiceTone && !VOICE_TONE_SUGGESTIONS.includes(initial.voiceTone))
);
const [mbti, setMbti] = useState<MbtiType | "">(initial.mbti || "");
const [status, setStatus] = useState<PersonaStatus>(initial.status || "active");
// Personality
const [personality, setPersonality] = useState<OceanTraits>(
initial.personality || OCEAN_PRESETS.balanced.values
);
// Rules & Rails
const [positiveRules, setPositiveRules] = useState<string[]>(initial.positiveRules || []);
const [negativeRules, setNegativeRules] = useState<string[]>(initial.negativeRules || []);
const [topicalRails, setTopicalRails] = useState<string[]>(initial.topicalRails || []);
const [newPositiveRule, setNewPositiveRule] = useState("");
const [newNegativeRule, setNewNegativeRule] = useState("");
const [newTopicalRail, setNewTopicalRail] = useState("");
// Backstory
const [backstory, setBackstory] = useState(initial.backstory || "");
const [worldBuilding, setWorldBuilding] = useState(initial.worldBuilding || "");
// Model Settings
const [defaultModel, setDefaultModel] = useState(initial.defaultModel || "gpt-4o");
const [temperature, setTemperature] = useState(initial.temperature ?? 0.7);
const [maxTokensPerTurn, setMaxTokensPerTurn] = useState(initial.maxTokensPerTurn ?? 1024);
const [guardrailsConfig, setGuardrailsConfig] = useState<GuardrailsConfig>(
initial.guardrailsConfig || { maxResponseLength: 2000, allowCodeExecution: false, allowExternalLinks: true }
);
// Memory
const [memorySettings, setMemorySettings] = useState<MemorySettings>(
initialConfig.memorySettings
);
const handlePersonaChange = (personaId: string) => {
setSelectedPersonaId(personaId);
const p = resolvePersona(personaId);
setName(p.name);
setArchetype(p.archetype || "");
setIsCustomArchetype(!!(p.archetype && !ARCHETYPE_SUGGESTIONS.includes(p.archetype)));
setVoiceTone(p.voiceTone || "");
setIsCustomVoiceTone(!!(p.voiceTone && !VOICE_TONE_SUGGESTIONS.includes(p.voiceTone)));
setMbti(p.mbti || "");
setStatus(p.status || "active");
setPersonality(p.personality);
setPositiveRules(p.positiveRules || []);
setNegativeRules(p.negativeRules || []);
setTopicalRails(p.topicalRails || []);
setBackstory(p.backstory || "");
setWorldBuilding(p.worldBuilding || "");
setDefaultModel(p.defaultModel || "gpt-4o");
setTemperature(p.temperature ?? 0.7);
setMaxTokensPerTurn(p.maxTokensPerTurn ?? 1024);
setGuardrailsConfig(p.guardrailsConfig || { maxResponseLength: 2000, allowCodeExecution: false, allowExternalLinks: true });
};
const handleSave = async () => {
if (!name.trim()) {
setError("Persona name is required");
setActiveTab("basic");
return;
}
setSaving(true);
setError(null);
setSaved(false);
try {
const updatedPersona: PersonaConfig = {
id: selectedPersonaId,
name: name.trim(),
archetype: archetype.trim() || undefined,
voiceTone: voiceTone.trim() || undefined,
mbti: mbti || undefined,
personality,
positiveRules: positiveRules.filter(Boolean),
negativeRules: negativeRules.filter(Boolean),
backstory: backstory.trim() || undefined,
worldBuilding: worldBuilding.trim() || undefined,
topicalRails: topicalRails.filter(Boolean),
defaultModel,
temperature,
maxTokensPerTurn,
guardrailsConfig,
status,
};
const res = await fetch("/api/agent/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agentName: agentName.trim(),
userName: userName.trim(),
activePersonaId: selectedPersonaId,
personas: initialConfig.personas.map((p) =>
p.id === selectedPersonaId ? updatedPersona : p
),
memorySettings,
}),
});
if (!res.ok) throw new Error("Failed to save");
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save settings");
} finally {
setSaving(false);
}
};
const handleClearHistory = async () => {
if (!confirm("Are you sure you want to clear your conversation history? This cannot be undone.")) return;
try {
await fetch("/api/agent/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clearHistory: true }),
});
} catch {
setError("Failed to clear history");
}
};
const addToList = (
setter: React.Dispatch<React.SetStateAction<string[]>>,
inputSetter: React.Dispatch<React.SetStateAction<string>>,
value: string
) => {
const trimmed = value.trim();
if (!trimmed) return;
setter((prev) => [...prev, trimmed]);
inputSetter("");
};
const removeFromList = (
setter: React.Dispatch<React.SetStateAction<string[]>>,
idx: number
) => {
setter((prev) => prev.filter((_, i) => i !== idx));
};
const tabItems = [
{ key: "basic", label: "Basic Info", icon: "ph-user-circle" },
{ key: "personality", label: "Personality", icon: "ph-brain" },
{ key: "rules", label: "Rules & Rails", icon: "ph-list-checks" },
{ key: "backstory", label: "Backstory", icon: "ph-book-open" },
{ key: "model", label: "Model", icon: "ph-cpu" },
{ key: "memory", label: "Memory", icon: "ph-database" },
];
return (
<div className="card">
<div className="card-header d-flex align-items-center">
<h5 className="mb-0">
<i className="ph-robot me-2"></i>
Agent Settings
</h5>
<div className="ms-auto d-flex gap-2 align-items-center">
{saved && <span className="text-success small">Saved!</span>}
{error && <span className="text-danger small">{error}</span>}
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? (
<><span className="spinner-border spinner-border-sm me-1"></span>Saving...</>
) : (
<><i className="ph-floppy-disk me-1"></i>Save</>
)}
</button>
</div>
</div>
{/* Persona selector */}
{initialConfig.personas.length > 1 && (
<div className="card-header border-top py-2">
<div className="d-flex align-items-center gap-2">
<label className="form-label mb-0 small fw-medium">Active Persona:</label>
<select
className="form-select form-select-sm"
style={{ width: "auto" }}
value={selectedPersonaId}
onChange={(e) => handlePersonaChange(e.target.value)}
>
{initialConfig.personas.map((p) => (
<option key={p.id} value={p.id}>
{p.name} {p.archetype ? `(${p.archetype})` : ""}
</option>
))}
</select>
</div>
</div>
)}
{/* Tabs */}
<div className="card-header p-0 border-top">
<ul className="nav nav-tabs nav-tabs-underline">
{tabItems.map((tab) => (
<li className="nav-item" key={tab.key}>
<button
type="button"
className={`nav-link ${activeTab === tab.key ? "active" : ""}`}
onClick={() => setActiveTab(tab.key)}
>
<i className={`${tab.icon} me-1`}></i>
{tab.label}
</button>
</li>
))}
</ul>
</div>
<div className="card-body">
{/* ── Tab: Basic Info ──────────────────────────────────────────── */}
{activeTab === "basic" && (
<div>
<div className="row g-3 mb-3">
<div className="col-md-6">
<label className="form-label fw-medium">
<i className="ph-robot me-1"></i>Agent Name
<HelpTip text="Give your agent a name so you can address it naturally in conversations, e.g. 'Hey Atlas, can you...'." />
</label>
<input
type="text"
className="form-control"
value={agentName}
onChange={(e) => setAgentName(e.target.value)}
placeholder="e.g. Atlas"
/>
<p className="text-muted small mt-1 mb-0">The name you use to talk to your agent.</p>
</div>
<div className="col-md-6">
<label className="form-label fw-medium">
<i className="ph-user me-1"></i>Your Name
<HelpTip text="The name your agent uses to address you. Defaults to your first name. Change it to a nickname or preferred name." />
</label>
<input
type="text"
className="form-control"
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="e.g. Max"
/>
<p className="text-muted small mt-1 mb-0">The name the agent should call you.</p>
</div>
</div>
<hr className="my-3" />
<h6 className="mb-3">
<i className="ph-mask-happy me-1"></i>
Persona
</h6>
<div className="mb-3">
<label className="form-label fw-medium">Persona Name <span className="text-danger">*</span><HelpTip text="The internal name of this persona configuration. Useful when you have multiple personas and need to tell them apart." /></label>
<input
type="text"
className="form-control"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Sage Advisor"
/>
</div>
<div className="row g-3 mb-3">
<div className="col-md-6">
<label className="form-label fw-medium">Archetype<HelpTip text="Choose a character archetype that defines how the agent approaches conversations. Each archetype brings a distinct behavioral pattern — e.g. The Mentor guides, The Challenger questions." /></label>
<select
className="form-select"
value={isCustomArchetype ? "__custom__" : archetype}
onChange={(e) => {
if (e.target.value === "__custom__") {
setIsCustomArchetype(true);
setArchetype("");
} else {
setIsCustomArchetype(false);
setArchetype(e.target.value);
}
}}
>
<option value="">None</option>
{ARCHETYPE_SUGGESTIONS.map((a) => (
<option key={a} value={a}>{a}</option>
))}
<option value="__custom__">Custom...</option>
</select>
{isCustomArchetype && (
<input
type="text"
className="form-control mt-2"
value={archetype}
onChange={(e) => setArchetype(e.target.value)}
placeholder="Enter custom archetype"
autoFocus
/>
)}
{archetype && ARCHETYPE_DESCRIPTIONS[archetype] ? (
<p className="text-muted small mt-1 mb-0">{ARCHETYPE_DESCRIPTIONS[archetype]}</p>
) : (
<p className="text-muted small mt-1 mb-0">Defines the character role and behavioral pattern of the agent.</p>
)}
</div>
<div className="col-md-6">
<label className="form-label fw-medium">MBTI Type<HelpTip text="Myers-Briggs Type Indicator. Influences how the agent communicates: I/E (introvert/extrovert), S/N (sensing/intuition), T/F (thinking/feeling), J/P (judging/perceiving)." /></label>
<select
className="form-select"
value={mbti}
onChange={(e) => setMbti(e.target.value as MbtiType | "")}
>
<option value="">None</option>
{MBTI_TYPES.map((type) => (
<option key={type} value={type}>{type} {MBTI_DESCRIPTIONS[type].split(" — ")[0]}</option>
))}
</select>
{mbti ? (
<p className="text-muted small mt-1 mb-0">{MBTI_DESCRIPTIONS[mbti]}</p>
) : (
<p className="text-muted small mt-1 mb-0">Shapes the agent&apos;s communication style based on Myers-Briggs personality dimensions.</p>
)}
</div>
</div>
<div className="mb-3">
<label className="form-label fw-medium">Voice &amp; Tone<HelpTip text="Controls the agent's writing style — formal vs casual, brief vs detailed, warm vs neutral. Pick a preset or write your own description." /></label>
<select
className="form-select"
value={isCustomVoiceTone ? "__custom__" : voiceTone}
onChange={(e) => {
if (e.target.value === "__custom__") {
setIsCustomVoiceTone(true);
setVoiceTone("");
} else {
setIsCustomVoiceTone(false);
setVoiceTone(e.target.value);
}
}}
>
<option value="">None</option>
{VOICE_TONE_SUGGESTIONS.map((v) => (
<option key={v} value={v}>{v}</option>
))}
<option value="__custom__">Custom...</option>
</select>
{isCustomVoiceTone && (
<input
type="text"
className="form-control mt-2"
value={voiceTone}
onChange={(e) => setVoiceTone(e.target.value)}
placeholder="Enter custom voice & tone"
autoFocus
/>
)}
<p className="text-muted small mt-1 mb-0">Defines the communication style of the agent.</p>
</div>
<div className="mb-3">
<label className="form-label fw-medium">Status<HelpTip text="Active: persona is live and usable. Inactive: temporarily disabled. Draft: work in progress, not yet ready for use." /></label>
<select
className="form-select"
value={status}
onChange={(e) => setStatus(e.target.value as PersonaStatus)}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="draft">Draft</option>
</select>
</div>
</div>
)}
{/* ── Tab: Personality (OCEAN) ─────────────────────────────────── */}
{activeTab === "personality" && (
<div>
<div className="d-flex flex-wrap gap-2 mb-4">
<span className="text-muted small align-self-center me-2">Presets:<HelpTip text="Quick-start personality profiles based on the Big Five (OCEAN) model. Click a preset to fill all five sliders, then fine-tune individually." /></span>
{Object.entries(OCEAN_PRESETS).map(([key, preset]) => (
<button
key={key}
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setPersonality(preset.values)}
>
{preset.name}
</button>
))}
</div>
{OCEAN_TRAITS.map((trait) => (
<div key={trait.key} className="mb-4">
<div className="d-flex justify-content-between align-items-center mb-1">
<label htmlFor={`${baseId}-${trait.key}`} className="form-label mb-0 fw-medium">
{trait.label}
</label>
<span className={`badge bg-${trait.color}`}>{personality[trait.key]}</span>
</div>
<p className="text-muted small mb-2">{trait.desc}</p>
<div className="d-flex align-items-center gap-2">
<span className="text-muted small" style={{ width: 80 }}>{trait.low}</span>
<input
type="range"
className="form-range flex-grow-1"
id={`${baseId}-${trait.key}`}
min="0" max="100" step="1"
value={personality[trait.key]}
onChange={(e) =>
setPersonality((prev) => ({ ...prev, [trait.key]: parseInt(e.target.value, 10) }))
}
/>
<span className="text-muted small" style={{ width: 80, textAlign: "right" }}>{trait.high}</span>
</div>
</div>
))}
<div className="mt-4 p-3 bg-light rounded">
<h6 className="mb-2"><i className="ph-chart-radar me-2"></i>Personality Summary</h6>
<div className="row g-2">
{OCEAN_TRAITS.map((trait) => (
<div key={trait.key} className="col-6 col-md-4">
<div className="d-flex align-items-center gap-2">
<div className="progress flex-grow-1" style={{ height: 8 }}>
<div className={`progress-bar bg-${trait.color}`} style={{ width: `${personality[trait.key]}%` }} />
</div>
<span className="text-muted small" style={{ width: 24 }}>{trait.label[0]}</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* ── Tab: Rules & Rails ───────────────────────────────────────── */}
{activeTab === "rules" && (
<div>
{/* Positive Rules */}
<div className="mb-4">
<h6 className="mb-2">
<i className="ph-check-circle text-success me-2"></i>
Positive Rules (Do)
<HelpTip text="Instructions the agent should always follow. E.g. 'Be concise', 'Use bullet points for lists', 'Always greet the user by name'." />
</h6>
<div className="list-group mb-2">
{positiveRules.map((rule, idx) => (
<div key={idx} className="list-group-item d-flex align-items-center">
<span className="flex-fill small">{rule}</span>
<button type="button" className="btn btn-sm btn-icon btn-outline-danger rounded-pill ms-2"
onClick={() => removeFromList(setPositiveRules, idx)}>
<i className="ph-x"></i>
</button>
</div>
))}
</div>
<div className="input-group input-group-sm">
<input type="text" className="form-control" placeholder="Add a positive rule..."
value={newPositiveRule} onChange={(e) => setNewPositiveRule(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") addToList(setPositiveRules, setNewPositiveRule, newPositiveRule); }}
/>
<button type="button" className="btn btn-success"
onClick={() => addToList(setPositiveRules, setNewPositiveRule, newPositiveRule)}>
<i className="ph-plus"></i>
</button>
</div>
</div>
{/* Negative Rules */}
<div className="mb-4">
<h6 className="mb-2">
<i className="ph-prohibit text-danger me-2"></i>
Negative Rules (Don&apos;t)
<HelpTip text="Behaviors the agent must avoid. E.g. 'Never share personal data', 'Do not make promises on behalf of the company', 'Avoid technical jargon'." />
</h6>
<div className="list-group mb-2">
{negativeRules.map((rule, idx) => (
<div key={idx} className="list-group-item d-flex align-items-center">
<span className="flex-fill small">{rule}</span>
<button type="button" className="btn btn-sm btn-icon btn-outline-danger rounded-pill ms-2"
onClick={() => removeFromList(setNegativeRules, idx)}>
<i className="ph-x"></i>
</button>
</div>
))}
</div>
<div className="input-group input-group-sm">
<input type="text" className="form-control" placeholder="Add a negative rule..."
value={newNegativeRule} onChange={(e) => setNewNegativeRule(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") addToList(setNegativeRules, setNewNegativeRule, newNegativeRule); }}
/>
<button type="button" className="btn btn-danger"
onClick={() => addToList(setNegativeRules, setNewNegativeRule, newNegativeRule)}>
<i className="ph-plus"></i>
</button>
</div>
</div>
{/* Topical Rails */}
<div>
<h6 className="mb-2">
<i className="ph-funnel text-info me-2"></i>
Topical Rails
<HelpTip text="Restrict the agent to specific topics. If set, the agent will only respond to questions within these topics and politely decline others. Leave empty for no restrictions." />
</h6>
<p className="text-muted small mb-2">Restrict the agent to specific topics. Leave empty for no restrictions.</p>
<div className="d-flex flex-wrap gap-2 mb-2">
{topicalRails.map((rail, idx) => (
<span key={idx} className="badge bg-info d-flex align-items-center gap-1">
{rail}
<button type="button" className="btn-close btn-close-white btn-close-sm"
style={{ fontSize: "0.5rem" }}
onClick={() => removeFromList(setTopicalRails, idx)}></button>
</span>
))}
</div>
<div className="input-group input-group-sm">
<input type="text" className="form-control" placeholder="Add a topic rail..."
value={newTopicalRail} onChange={(e) => setNewTopicalRail(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") addToList(setTopicalRails, setNewTopicalRail, newTopicalRail); }}
/>
<button type="button" className="btn btn-info"
onClick={() => addToList(setTopicalRails, setNewTopicalRail, newTopicalRail)}>
<i className="ph-plus"></i>
</button>
</div>
</div>
</div>
)}
{/* ── Tab: Backstory ───────────────────────────────────────────── */}
{activeTab === "backstory" && (
<div>
<div className="mb-4">
<label className="form-label fw-medium">Character Backstory<HelpTip text="Give your agent a fictional or real background story. This shapes its perspective and how it frames responses. E.g. 'A seasoned IT consultant with 20 years of experience in cybersecurity'." /></label>
<textarea
className="form-control"
rows={6}
value={backstory}
onChange={(e) => setBackstory(e.target.value)}
placeholder="Describe the agent's character history, experience, and background..."
/>
<p className="text-muted small mt-1 mb-0">This shapes how the agent presents itself and responds.</p>
</div>
<div>
<label className="form-label fw-medium">World Building<HelpTip text="Describe the environment or context the agent operates in. E.g. 'A corporate environment focused on data privacy and compliance' or 'A creative studio for brainstorming marketing campaigns'." /></label>
<textarea
className="form-control"
rows={6}
value={worldBuilding}
onChange={(e) => setWorldBuilding(e.target.value)}
placeholder="Describe the context and environment the agent operates in..."
/>
<p className="text-muted small mt-1 mb-0">Defines the setting and context for agent interactions.</p>
</div>
</div>
)}
{/* ── Tab: Model Settings ──────────────────────────────────────── */}
{activeTab === "model" && (
<div>
<div className="mb-4">
<label className="form-label fw-medium">Default Model<HelpTip text="The AI model used for generating responses. Larger models (GPT-4o, Claude 3 Opus) are more capable but slower. Smaller models (Mini, Haiku) are faster and cheaper." /></label>
<select
className="form-select"
value={defaultModel}
onChange={(e) => setDefaultModel(e.target.value)}
>
{AVAILABLE_MODELS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
<div className="mb-4">
<label className="form-label fw-medium">
Temperature
<span className="badge bg-secondary ms-2">{temperature.toFixed(1)}</span>
<HelpTip text="Controls randomness in responses. Low (0.0-0.3): factual and consistent, ideal for support. Medium (0.4-0.7): balanced. High (0.8-2.0): creative and varied, good for brainstorming." />
</label>
<p className="text-muted small mb-2">
Lower = more focused and deterministic. Higher = more creative and varied.
</p>
<input
type="range" className="form-range"
min="0" max="2" step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
/>
<div className="d-flex justify-content-between text-muted small">
<span>Precise (0.0)</span>
<span>Creative (2.0)</span>
</div>
</div>
<div className="mb-4">
<label className="form-label fw-medium">
Max Tokens per Turn
<span className="badge bg-secondary ms-2">{maxTokensPerTurn}</span>
<HelpTip text="Maximum length of each agent response in tokens (~1 token = 4 characters). Lower values keep answers short, higher values allow detailed explanations." />
</label>
<input
type="range" className="form-range"
min="256" max="16384" step="256"
value={maxTokensPerTurn}
onChange={(e) => setMaxTokensPerTurn(parseInt(e.target.value, 10))}
/>
<div className="d-flex justify-content-between text-muted small">
<span>Short (256)</span>
<span>Long (16384)</span>
</div>
</div>
<h6 className="mb-3">
<i className="ph-shield-check me-2"></i>
Guardrails
<HelpTip text="Safety controls that limit what the agent can do. Use these to enforce compliance policies and restrict potentially risky behaviors." />
</h6>
<div className="mb-3">
<label className="form-label fw-medium">
Max Response Length
<span className="badge bg-secondary ms-2">{guardrailsConfig.maxResponseLength}</span>
<HelpTip text="Hard character limit on agent responses. Responses exceeding this limit are truncated. Use this as an additional safeguard alongside Max Tokens." />
</label>
<input
type="range" className="form-range"
min="500" max="8000" step="500"
value={guardrailsConfig.maxResponseLength}
onChange={(e) => setGuardrailsConfig((prev) => ({ ...prev, maxResponseLength: parseInt(e.target.value, 10) }))}
/>
<div className="d-flex justify-content-between text-muted small">
<span>500</span>
<span>8000</span>
</div>
</div>
<div className="form-check form-switch mb-2">
<input type="checkbox" className="form-check-input" id={`${baseId}-code-exec`}
checked={guardrailsConfig.allowCodeExecution}
onChange={(e) => setGuardrailsConfig((prev) => ({ ...prev, allowCodeExecution: e.target.checked }))}
/>
<label className="form-check-label" htmlFor={`${baseId}-code-exec`}>Allow Code Execution<HelpTip text="When enabled, the agent can execute code snippets (e.g. Python, SQL) to answer questions. Disable this if the agent should only provide text-based responses." /></label>
</div>
<div className="form-check form-switch mb-2">
<input type="checkbox" className="form-check-input" id={`${baseId}-ext-links`}
checked={guardrailsConfig.allowExternalLinks}
onChange={(e) => setGuardrailsConfig((prev) => ({ ...prev, allowExternalLinks: e.target.checked }))}
/>
<label className="form-check-label" htmlFor={`${baseId}-ext-links`}>Allow External Links<HelpTip text="When enabled, the agent can include URLs and link to external resources in responses. Disable this to keep all responses self-contained without outbound references." /></label>
</div>
</div>
)}
{/* ── Tab: Memory & Context ────────────────────────────────────── */}
{activeTab === "memory" && (
<div>
<div className="mb-4">
<label className="form-label fw-medium">Conversation Retention<HelpTip text="How long the agent remembers past conversations for context. Longer retention means the agent can reference older discussions, but uses more storage." /></label>
<select
className="form-select"
value={memorySettings.retentionDays}
onChange={(e) => setMemorySettings({ retentionDays: parseInt(e.target.value, 10) as 7 | 30 | 90 })}
>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
<p className="text-muted small mt-1">How long to keep conversation history for context.</p>
</div>
<div className="border-top pt-3">
<h6 className="text-danger mb-2">Danger Zone<HelpTip text="Irreversible actions. Clearing history removes all past conversations and the agent will have no memory of previous interactions." /></h6>
<button type="button" className="btn btn-outline-danger btn-sm" onClick={handleClearHistory}>
<i className="ph-trash me-1"></i>
Clear Conversation History
</button>
<p className="text-muted small mt-1">
Permanently delete all conversation history. This cannot be undone.
</p>
</div>
</div>
)}
</div>
</div>
);
}

11
src/config/brand.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { Brand } from "@gsc/web-kit/chrome";
export const brand: Brand = {
name: "My",
product: "GoSec My Account",
logoUrl: "https://assets.gosec.cloud/logos/logo.svg",
websiteUrl: "https://www.gosec.cloud",
supportUrl: "https://support.gosec.cloud/",
docsUrl: "https://support.gosec.cloud/support/?target=my",
copyrightStartYear: 2023,
};

View File

@@ -0,0 +1,79 @@
[
{
"id": 100001,
"icon": "ph-house",
"name": "Dashboard",
"url": "/",
"key": "Dashboard",
"submenulvl1": []
},
{
"id": 100002,
"icon": "ph-user-circle",
"name": "Account",
"url": "#",
"key": "Account",
"submenulvl1": [
{
"name": "Profile",
"url": "/profile",
"key": "account_profile",
"icon": "ph-identification-card"
},
{
"name": "Settings",
"url": "/settings",
"key": "account_settings",
"icon": "ph-gear"
},
{
"name": "Security",
"url": "/security",
"key": "account_security",
"icon": "ph-shield-check"
},
{
"name": "Privacy",
"url": "/privacy",
"key": "account_privacy",
"icon": "ph-lock-key"
},
{
"name": "Analytics",
"url": "/analytics",
"key": "account_analytics",
"icon": "ph-chart-line-up"
}
]
},
{
"id": 100003,
"icon": "ph-chat-circle-dots",
"name": "Communication",
"url": "#",
"key": "Communication",
"submenulvl1": [
{
"name": "Voice",
"url": "/voice",
"key": "comm_voice",
"icon": "ph-phone"
}
]
},
{
"id": 100004,
"icon": "ph-brain",
"name": "AI",
"url": "#",
"key": "AI",
"submenulvl1": [
{
"name": "Agent",
"url": "/agent",
"key": "ai_agent",
"icon": "ph-robot"
}
]
}
]

View File

@@ -0,0 +1,11 @@
[
{
"site": {
"name": "GoSec Cloud",
"product": "My Settings"
},
"company": {
"website": "https://my.gosec.cloud"
}
}
]

View File

@@ -0,0 +1,30 @@
[
{
"id": 98577,
"icon": "ph-shield-warning",
"name": "Security",
"url": "/security",
"key": "Security"
},
{
"id": 36845,
"icon": "ph-chart-bar",
"name": "Analytics",
"url": "/analytics",
"key": "Analytics"
},
{
"id": 35567,
"icon": "ph-lock-key",
"name": "Privacy",
"url": "/privacy",
"key": "Privacy"
},
{
"id": 45678,
"icon": "ph-phone",
"name": "Voice",
"url": "/voice",
"key": "Voice"
}
]

View File

@@ -0,0 +1,16 @@
[
{
"id": 13677,
"icon": "ph-user-circle",
"name": "My profile",
"url": "/profile",
"key": "MyProfile"
},
{
"id": 5473,
"icon": "ph-gear",
"name": "Account settings",
"url": "/settings",
"key": "AccountSettings"
}
]

45
src/database/activity.ts Normal file
View File

@@ -0,0 +1,45 @@
import prisma from "./prisma";
export interface ActivityEntry {
action: string;
target?: string;
metadata?: Record<string, unknown>;
ipAddress?: string;
userAgent?: string;
}
export async function logUserActivity(
userId: string,
tenantId: string,
entry: ActivityEntry
) {
return prisma.userActivityLog.create({
data: {
userId,
tenantId,
action: entry.action,
target: entry.target,
metadata: (entry.metadata ?? {}) as never,
ipAddress: entry.ipAddress,
userAgent: entry.userAgent,
},
});
}
export async function getUserActivity(
userId: string,
options: { limit?: number; offset?: number } = {}
) {
const { limit = 20, offset = 0 } = options;
return prisma.userActivityLog.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
});
}
export async function getUserActivityCount(userId: string) {
return prisma.userActivityLog.count({ where: { userId } });
}

18
src/database/pbx-pool.ts Normal file
View File

@@ -0,0 +1,18 @@
import pg from "pg";
const globalForPbx = globalThis as unknown as {
pbxPool: pg.Pool | undefined;
};
export const pbxPool =
globalForPbx.pbxPool ??
new pg.Pool({
connectionString: process.env.PBX_DATABASE_URL,
max: 5,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
if (process.env.NODE_ENV !== "production") globalForPbx.pbxPool = pbxPool;
export default pbxPool;

232
src/database/pbx.ts Normal file
View File

@@ -0,0 +1,232 @@
import pbxPool from "./pbx-pool";
export interface PbxExtension {
id: string;
extension: string;
name: string;
mohClassId: string | null;
}
export interface PbxFollowMe {
id: string;
isEnabled: boolean;
ringTime: number;
numbers: Array<{
id: string;
number: string;
ringDelay: number;
orderNum: number;
}>;
}
export interface PbxVoicemailBox {
id: string;
mailbox: string;
email: string | null;
emailNotify: boolean;
attachVoicemail: boolean;
newMessages: number;
}
export interface PbxMohClass {
id: string;
name: string;
isDefault: boolean;
}
/**
* Get extension for a user by their gsc_core user ID (matches pbx_extensions.user_id).
*/
export async function getUserExtension(userId: string, tenantId: string): Promise<PbxExtension | null> {
const result = await pbxPool.query(
`SELECT e.id, e.extension, e.name, e.metadata
FROM pbx_extensions e
WHERE e.user_id = $1 AND e.tenant_id = $2 AND e.is_active = true
LIMIT 1`,
[userId, tenantId]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
// MOH class ID may be stored in extension metadata
const meta = row.metadata as Record<string, unknown> | null;
return {
id: row.id,
extension: row.extension,
name: row.name,
mohClassId: (meta?.moh_class_id as string) || null,
};
}
/**
* Get follow-me (call forwarding) config for an extension.
*/
export async function getFollowMe(extensionId: string): Promise<PbxFollowMe | null> {
const fmResult = await pbxPool.query(
`SELECT id, is_enabled, ring_time
FROM pbx_follow_me
WHERE extension_id = $1
LIMIT 1`,
[extensionId]
);
if (fmResult.rows.length === 0) return null;
const fm = fmResult.rows[0];
const numResult = await pbxPool.query(
`SELECT id, number, ring_time, order_num
FROM pbx_follow_me_numbers
WHERE follow_me_id = $1
ORDER BY order_num ASC`,
[fm.id]
);
return {
id: fm.id,
isEnabled: fm.is_enabled,
ringTime: fm.ring_time,
numbers: numResult.rows.map((r) => ({
id: r.id,
number: r.number,
ringDelay: r.ring_time,
orderNum: r.order_num,
})),
};
}
/**
* Get voicemail box for an extension.
*/
export async function getVoicemailBox(extensionId: string): Promise<PbxVoicemailBox | null> {
const result = await pbxPool.query(
`SELECT id, mailbox, email, mwi_enabled, email_attachment, new_messages
FROM pbx_voicemail_boxes
WHERE extension_id = $1 AND is_active = true
LIMIT 1`,
[extensionId]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
id: row.id,
mailbox: row.mailbox,
email: row.email,
emailNotify: row.mwi_enabled ?? true,
attachVoicemail: row.email_attachment ?? true,
newMessages: row.new_messages ?? 0,
};
}
/**
* Get music-on-hold classes for a tenant.
*/
export async function getMohClasses(tenantId: string): Promise<PbxMohClass[]> {
const result = await pbxPool.query(
`SELECT id, name, is_default
FROM pbx_moh_classes
WHERE tenant_id = $1 AND is_active = true
ORDER BY name ASC`,
[tenantId]
);
return result.rows.map((r) => ({
id: r.id,
name: r.name,
isDefault: r.is_default,
}));
}
/**
* Upsert follow-me configuration.
*/
export async function upsertFollowMe(
extensionId: string,
data: { isEnabled: boolean; ringTime: number; numbers: Array<{ number: string; ringDelay: number; orderNum: number }> }
) {
const client = await pbxPool.connect();
try {
await client.query("BEGIN");
const fmResult = await client.query(
`INSERT INTO pbx_follow_me (extension_id, is_enabled, ring_time)
VALUES ($1, $2, $3)
ON CONFLICT (extension_id) DO UPDATE SET is_enabled = $2, ring_time = $3, updated_at = NOW()
RETURNING id`,
[extensionId, data.isEnabled, data.ringTime]
);
const followMeId = fmResult.rows[0].id;
await client.query("DELETE FROM pbx_follow_me_numbers WHERE follow_me_id = $1", [followMeId]);
for (const num of data.numbers) {
await client.query(
`INSERT INTO pbx_follow_me_numbers (follow_me_id, number, ring_time, order_num)
VALUES ($1, $2, $3, $4)`,
[followMeId, num.number, num.ringDelay, num.orderNum]
);
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
/**
* Update extension metadata (e.g., MOH class).
*/
export async function updateExtension(
tenantId: string,
extensionId: string,
data: { mohClassId?: string | null }
) {
if (data.mohClassId !== undefined) {
// Store MOH class ID in extension metadata
await pbxPool.query(
`UPDATE pbx_extensions
SET metadata = jsonb_set(COALESCE(metadata, '{}'), '{moh_class_id}', $1::jsonb),
updated_at = NOW()
WHERE tenant_id = $2 AND id = $3`,
[JSON.stringify(data.mohClassId), tenantId, extensionId]
);
}
}
/**
* Update voicemail box settings.
*/
export async function updateVoicemailBox(
tenantId: string,
voicemailBoxId: string,
data: { email?: string | null; emailNotify?: boolean; attachVoicemail?: boolean; pin?: string }
) {
const sets: string[] = [];
const params: unknown[] = [];
let idx = 1;
if (data.email !== undefined) {
sets.push(`email = $${idx++}`);
params.push(data.email);
}
if (data.emailNotify !== undefined) {
sets.push(`mwi_enabled = $${idx++}`);
params.push(data.emailNotify);
}
if (data.attachVoicemail !== undefined) {
sets.push(`email_attachment = $${idx++}`);
params.push(data.attachVoicemail);
}
if (data.pin) {
sets.push(`pin_encrypted = $${idx++}`);
params.push(data.pin);
}
if (sets.length === 0) return;
sets.push(`updated_at = NOW()`);
params.push(voicemailBoxId);
await pbxPool.query(
`UPDATE pbx_voicemail_boxes SET ${sets.join(", ")} WHERE id = $${idx}`,
params
);
}

15
src/database/prisma.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export default prisma;

151
src/database/settings.ts Normal file
View File

@@ -0,0 +1,151 @@
import prisma from "./prisma";
export interface EffectiveSetting {
category: string;
key: string;
value: unknown;
source: "user" | "tenant" | "customer" | "default";
dataType: string;
description: string | null;
allowedValues: unknown;
}
/**
* Resolve effective settings using the 3-level cascade:
* definition default → customer override → tenant override → user override
*
* Only user-overridable settings are included.
*/
export async function getUserEffectiveSettings(
userId: string,
tenantId: string,
customerId: string | undefined,
categories: string[]
): Promise<Record<string, Record<string, EffectiveSetting>>> {
// 1. Fetch all definitions for requested categories
const definitions = await prisma.settingsDefinition.findMany({
where: { category: { in: categories }, allow_user_override: true },
orderBy: [{ category: "asc" }, { display_order: "asc" }],
});
// 2. Fetch customer overrides
const customerSettings = customerId
? await prisma.customerSetting.findMany({
where: {
customerId,
settingId: { in: definitions.map((d) => d.id) },
},
})
: [];
// 3. Fetch tenant overrides
const tenantSettings = await prisma.tenantSetting.findMany({
where: {
tenantId,
settingId: { in: definitions.map((d) => d.id) },
},
});
// 4. Fetch user overrides
const userSettings = await prisma.userSetting.findMany({
where: {
userId,
settingId: { in: definitions.map((d) => d.id) },
},
});
// Build lookup maps
const custMap = new Map(customerSettings.map((s) => [s.settingId, s]));
const tenantMap = new Map(tenantSettings.map((s) => [s.settingId, s]));
const userMap = new Map(userSettings.map((s) => [s.settingId, s]));
// 5. Resolve cascade
const result: Record<string, Record<string, EffectiveSetting>> = {};
for (const def of definitions) {
let value: unknown = def.defaultValue;
let source: EffectiveSetting["source"] = "default";
const custSetting = custMap.get(def.id);
if (custSetting) {
value = custSetting.value;
source = "customer";
}
const tenantSetting = tenantMap.get(def.id);
if (tenantSetting) {
// Only override if customer didn't mark as mandatory
if (!custSetting?.is_mandatory || custSetting.allow_tenant_override) {
value = tenantSetting.value;
source = "tenant";
}
}
const userSetting = userMap.get(def.id);
if (userSetting) {
// Only override if tenant didn't mark as mandatory
const canUserOverride = tenantSetting ? tenantSetting.allow_user_override !== false : true;
if (canUserOverride) {
value = userSetting.value;
source = "user";
}
}
if (!result[def.category]) result[def.category] = {};
result[def.category][def.key] = {
category: def.category,
key: def.key,
value,
source,
dataType: def.dataType,
description: def.description,
allowedValues: def.allowed_values,
};
}
return result;
}
/**
* Batch upsert user settings. Each entry is { category, key, value }.
*/
export async function batchUpsertUserSettings(
userId: string,
settings: Array<{ category: string; key: string; value: unknown }>
) {
// Look up setting definition IDs
const keys = settings.map((s) => ({ category: s.category, key: s.key }));
const definitions = await prisma.settingsDefinition.findMany({
where: {
OR: keys.map((k) => ({ category: k.category, key: k.key })),
},
select: { id: true, category: true, key: true },
});
const defMap = new Map(definitions.map((d) => [`${d.category}:${d.key}`, d.id]));
// Build upserts
const operations = settings
.map((s) => {
const settingId = defMap.get(`${s.category}:${s.key}`);
if (!settingId) return null;
return prisma.userSetting.upsert({
where: { userId_settingId: { userId, settingId } },
update: { value: s.value as never },
create: { userId, settingId, value: s.value as never },
});
})
.filter(Boolean) as ReturnType<typeof prisma.userSetting.upsert>[];
await prisma.$transaction(operations);
}
/**
* Get settings definitions for given categories.
*/
export async function getSettingsDefinitions(categories: string[]) {
return prisma.settingsDefinition.findMany({
where: { category: { in: categories } },
orderBy: [{ category: "asc" }, { display_order: "asc" }],
});
}

72
src/database/users.ts Normal file
View File

@@ -0,0 +1,72 @@
import prisma from "./prisma";
export async function getUserByGscsid(gscsid: string) {
return prisma.user.findUnique({
where: { gscsid },
select: {
id: true,
gscsid: true,
firstName: true,
lastName: true,
displayName: true,
email: true,
timezone: true,
locale: true,
createdAt: true,
},
});
}
interface EnsureUserData {
email?: string | null;
firstName?: string | null;
lastName?: string | null;
}
/**
* Get existing user by gscsid, or create a minimal record on first login.
* Returns the user with id and optional customerId (from metadata).
*/
export async function ensureUser(gscsid: string, data: EnsureUserData) {
let user = await prisma.user.findUnique({
where: { gscsid },
select: {
id: true,
gscsid: true,
email: true,
firstName: true,
lastName: true,
displayName: true,
metadata: true,
createdAt: true,
},
});
if (!user) {
user = await prisma.user.create({
data: {
gscsid,
email: data.email,
firstName: data.firstName,
lastName: data.lastName,
displayName: [data.firstName, data.lastName].filter(Boolean).join(" ") || gscsid,
},
select: {
id: true,
gscsid: true,
email: true,
firstName: true,
lastName: true,
displayName: true,
metadata: true,
createdAt: true,
},
});
}
// Extract customerId from metadata if present
const meta = user.metadata as Record<string, unknown> | null;
const customerId = (meta?.customerId || meta?.customer_id) as string | undefined;
return { ...user, customerId };
}

46
src/i18n/request.ts Normal file
View File

@@ -0,0 +1,46 @@
import { getRequestConfig } from "next-intl/server";
import { cookies, headers } from "next/headers";
export const locales = ["en", "de", "fr"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
export default getRequestConfig(async () => {
// Try to get locale from cookie
const cookieStore = await cookies();
let locale = cookieStore.get("NEXT_LOCALE")?.value as Locale | undefined;
// Fall back to Accept-Language header
if (!locale || !locales.includes(locale)) {
const headersList = await headers();
const acceptLanguage = headersList.get("accept-language");
if (acceptLanguage) {
const preferredLocale = acceptLanguage
.split(",")[0]
?.split("-")[0] as Locale;
if (locales.includes(preferredLocale)) {
locale = preferredLocale;
}
}
}
// Fall back to default
if (!locale || !locales.includes(locale)) {
locale = defaultLocale;
}
return {
locale,
messages: (await import(`../../public/locales/${locale}/common.json`)).default,
onError(error) {
if (error.code === "MISSING_MESSAGE") {
console.warn(error.message);
} else {
console.error(error);
}
},
getMessageFallback({ namespace, key }) {
return key;
},
};
});

BIN
src/images/boxed_bg.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
src/images/layers-2x.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src/images/layers.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

BIN
src/images/login_cover.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

BIN
src/images/marker-icon-2x.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
src/images/marker-icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
src/images/marker-shadow.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

174
src/lib/authz.ts Normal file
View File

@@ -0,0 +1,174 @@
// Runtime authorization for gscAdmin.
//
// Two sources of authority, evaluated in this order:
// 1. Base roles in the JWT — set at login from the gosecCloud realm
// `gsc-roles` LDAP mapper (FreeIPA group → realm role). These are
// always-on permissions; static for the life of the session.
// 2. Active JIT grants in `admin.privilege_grants` — written by the
// PAM request flow, expire after the policy's max_duration_hours.
// DB-backed so privileges activate/revoke without forcing a
// relogin. Cached in-process for 15 s to keep the hot path cheap.
//
// See docs/pam-plan.md for the broader architecture.
import { prisma } from "@/database/prisma";
import type { SessionUser } from "@/auth";
// --- Errors ----------------------------------------------------------
export class ForbiddenError extends Error {
constructor(public readonly role: string) {
super(`Forbidden: missing role '${role}'`);
this.name = "ForbiddenError";
}
}
// --- In-process TTL cache --------------------------------------------
//
// Single map shared across requests in this Node process. Key is the
// `gscsid:role` pair; value is the boolean grant + the epoch ms when
// the entry expires. We accept ≤15 s of staleness for revocations and
// new grants — the plan locks this in as decision #4.
const CACHE_TTL_MS = 15_000;
type CacheEntry = { hit: boolean; expiresAt: number };
const cache = new Map<string, CacheEntry>();
function cacheKey(gscsid: string, role: string): string {
return `${gscsid}:${role}`;
}
function readCache(gscsid: string, role: string): boolean | undefined {
const key = cacheKey(gscsid, role);
const entry = cache.get(key);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
cache.delete(key);
return undefined;
}
return entry.hit;
}
function writeCache(gscsid: string, role: string, hit: boolean): void {
cache.set(cacheKey(gscsid, role), {
hit,
expiresAt: Date.now() + CACHE_TTL_MS,
});
}
/**
* Invalidate any cached authz decisions for a single grant. Call this
* right after writing/revoking a grant so the issuing pod sees the
* change immediately (other pods catch up within the TTL).
*/
export function invalidateAuthzCache(gscsid: string, role: string): void {
cache.delete(cacheKey(gscsid, role));
}
// --- DB lookup -------------------------------------------------------
async function hasActiveGrant(gscsid: string, role: string): Promise<boolean> {
const cached = readCache(gscsid, role);
if (cached !== undefined) return cached;
const grant = await prisma.privilegeGrant.findFirst({
where: {
gscsid,
roleName: role,
status: "active",
expiresAt: { gt: new Date() },
},
select: { id: true },
});
const hit = grant !== null;
writeCache(gscsid, role, hit);
return hit;
}
// --- Public API ------------------------------------------------------
/**
* True if the user holds `role` either as a base realm role or via an
* active JIT grant. Safe to call on every privileged code path —
* single indexed DB hit at most every 15 s per (user, role).
*/
export async function hasRole(
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
role: string,
): Promise<boolean> {
if (!user?.id) return false;
if (user.roles.includes(role)) return true;
return await hasActiveGrant(user.id, role);
}
/**
* Throws ForbiddenError if the user lacks `role`. Use in server
* actions / route handlers / pages where missing access should 403.
*/
export async function requireRole(
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
role: string,
): Promise<void> {
if (!(await hasRole(user, role))) {
throw new ForbiddenError(role);
}
}
/**
* Page-friendly variant: if the user can't reach `role`, bounce them
* to /access?need=<role> instead of throwing. Use at the top of
* server components so the user sees the elevation UI rather than
* a generic error page.
*
* await requireElevation(user, "gscadmin_admins");
*/
export async function requireElevation(
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
role: string,
): Promise<void> {
if (await hasRole(user, role)) return;
const { redirect } = await import("next/navigation");
redirect(`/access?need=${encodeURIComponent(role)}`);
}
/**
* Variant: true if the user holds *any* of the listed roles. Useful
* for "operator OR admin" gates.
*/
export async function hasAnyRole(
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
roles: readonly string[],
): Promise<boolean> {
for (const r of roles) {
if (await hasRole(user, r)) return true;
}
return false;
}
/**
* Variant of requireRole that admits any of the listed roles.
*/
export async function requireAnyRole(
user: Pick<SessionUser, "id" | "roles"> | null | undefined,
roles: readonly string[],
): Promise<void> {
if (!(await hasAnyRole(user, roles))) {
throw new ForbiddenError(roles.join(" | "));
}
}
/**
* Roles the user can JIT-elevate to right now. Derived from the
* `*_eligible` realm roles already in the JWT — no DB hit required.
* Each entry is the *target* role (without the `_eligible` suffix).
*
* Example: JWT has `gsccrm_admins_eligible` → returns `["gsccrm_admins"]`.
*/
export function eligibleRoles(user: Pick<SessionUser, "roles"> | null | undefined): string[] {
if (!user?.roles) return [];
return user.roles
.filter((r) => r.endsWith("_eligible"))
.map((r) => r.slice(0, -"_eligible".length));
}

147
src/lib/keycloak.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* Keycloak Admin API client for fetching login events, sessions, and credentials.
* Uses service account token obtained via client_credentials grant.
*/
const KEYCLOAK_ISSUER = process.env.AUTH_KEYCLOAK_ISSUER || "";
const KEYCLOAK_CLIENT_ID = process.env.AUTH_KEYCLOAK_ID || "";
const KEYCLOAK_CLIENT_SECRET = process.env.AUTH_KEYCLOAK_SECRET || "";
// Derive admin base URL from issuer (e.g., https://auth.gosec.cloud/realms/gosecCloud → https://auth.gosec.cloud)
function getAdminBaseUrl(): string {
const url = new URL(KEYCLOAK_ISSUER);
return `${url.protocol}//${url.host}`;
}
function getRealmName(): string {
// issuer format: https://host/realms/{realm}
const parts = KEYCLOAK_ISSUER.split("/realms/");
return parts[1] || "gosecCloud";
}
let cachedToken: { token: string; expiresAt: number } | null = null;
async function getServiceAccountToken(): Promise<string> {
if (cachedToken && Date.now() < cachedToken.expiresAt) {
return cachedToken.token;
}
const res = await fetch(`${KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: KEYCLOAK_CLIENT_ID,
client_secret: KEYCLOAK_CLIENT_SECRET,
}),
});
if (!res.ok) {
throw new Error(`Keycloak token request failed: ${res.status}`);
}
const data = await res.json();
cachedToken = {
token: data.access_token,
expiresAt: Date.now() + (data.expires_in - 30) * 1000,
};
return cachedToken.token;
}
async function adminFetch(path: string): Promise<unknown> {
const token = await getServiceAccountToken();
const baseUrl = getAdminBaseUrl();
const realm = getRealmName();
const url = `${baseUrl}/admin/realms/${realm}${path}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
next: { revalidate: 0 },
});
if (!res.ok) {
throw new Error(`Keycloak admin API error: ${res.status} ${res.statusText}`);
}
return res.json();
}
// ─── Public API ─────────────────────────────────────────────
export interface KeycloakLoginEvent {
time: number;
type: string;
clientId: string;
userId: string;
ipAddress: string;
details?: Record<string, string>;
error?: string;
}
export interface KeycloakSession {
id: string;
userId: string;
ipAddress: string;
start: number;
lastAccess: number;
clients: Record<string, string>;
}
/**
* Get login events for a user (both successful and failed).
*/
export async function getLoginEvents(keycloakId: string, maxResults = 50): Promise<KeycloakLoginEvent[]> {
try {
const events = (await adminFetch(
`/events?userId=${keycloakId}&type=LOGIN&type=LOGIN_ERROR&max=${maxResults}&first=0`
)) as KeycloakLoginEvent[];
return events;
} catch (err) {
console.warn("[keycloak] Failed to fetch login events:", err instanceof Error ? err.message : err);
return [];
}
}
/**
* Get active sessions for a user.
*/
export async function getUserSessions(keycloakId: string): Promise<KeycloakSession[]> {
try {
const sessions = (await adminFetch(`/users/${keycloakId}/sessions`)) as KeycloakSession[];
return sessions;
} catch (err) {
console.warn("[keycloak] Failed to fetch user sessions:", err instanceof Error ? err.message : err);
return [];
}
}
/**
* Check if user has TOTP (2FA) configured.
*/
export async function getUserHas2FA(keycloakId: string): Promise<boolean> {
try {
const credentials = (await adminFetch(`/users/${keycloakId}/credentials`)) as Array<{
type: string;
id: string;
}>;
return credentials.some((c) => c.type === "otp");
} catch (err) {
console.warn("[keycloak] Failed to fetch user credentials:", err instanceof Error ? err.message : err);
return false;
}
}
/**
* Get admin events (password changes, 2FA config, etc.) for a user.
*/
export async function getAdminEvents(keycloakId: string, maxResults = 20): Promise<KeycloakLoginEvent[]> {
try {
const events = (await adminFetch(
`/admin-events?userId=${keycloakId}&max=${maxResults}&first=0`
)) as KeycloakLoginEvent[];
return events;
} catch (err) {
console.warn("[keycloak] Failed to fetch admin events:", err instanceof Error ? err.message : err);
return [];
}
}

98
src/lib/pam-mail.ts Normal file
View File

@@ -0,0 +1,98 @@
// PAM approval-email sender.
//
// `manual`-mode privilege requests send a mail to the policy's
// `approver_email` containing a one-click approval link. The link
// targets /api/pam/approve/<token>; the token is single-use and
// rotates the grant's status from `pending` → `active`.
//
// SMTP is configured via env vars (all optional):
// PAM_SMTP_HOST, PAM_SMTP_PORT (default 587),
// PAM_SMTP_USER, PAM_SMTP_PASSWORD,
// PAM_SMTP_FROM (default 'no-reply@gosec.cloud'),
// PAM_SMTP_SECURE ('true' for implicit TLS, default false → STARTTLS).
//
// If PAM_SMTP_HOST is unset, sendApprovalEmail() throws — manual-mode
// request handlers should catch this and refuse the request with a
// clear error so the user knows to ask an operator to configure SMTP.
import nodemailer from "nodemailer";
export interface ApprovalEmailInput {
to: string;
approveUrl: string;
requesterDisplay: string; // "Olivia Smith <olivia.smith@…>" or just gscsid
roleName: string;
durationHours: number;
justification: string;
}
export function isMailConfigured(): boolean {
return !!process.env.PAM_SMTP_HOST;
}
let cachedTransport: nodemailer.Transporter | null = null;
function getTransport(): nodemailer.Transporter {
if (cachedTransport) return cachedTransport;
const host = process.env.PAM_SMTP_HOST;
if (!host) {
throw new Error(
"SMTP not configured — set PAM_SMTP_HOST (and PAM_SMTP_USER/PAM_SMTP_PASSWORD if auth is required) to enable manual-mode PAM approvals.",
);
}
cachedTransport = nodemailer.createTransport({
host,
port: parseInt(process.env.PAM_SMTP_PORT ?? "587", 10),
secure: process.env.PAM_SMTP_SECURE === "true",
auth: process.env.PAM_SMTP_USER
? {
user: process.env.PAM_SMTP_USER,
pass: process.env.PAM_SMTP_PASSWORD ?? "",
}
: undefined,
});
return cachedTransport;
}
export async function sendApprovalEmail(input: ApprovalEmailInput): Promise<void> {
const t = getTransport();
const from = process.env.PAM_SMTP_FROM ?? "no-reply@gosec.cloud";
const subject = `[PAM] Approval required: ${input.roleName} for ${input.requesterDisplay}`;
const text = [
`${input.requesterDisplay} has requested privileged role:`,
"",
` Role: ${input.roleName}`,
` Duration: ${input.durationHours} hour(s)`,
` Justification: ${input.justification}`,
"",
`Approve: ${input.approveUrl}`,
"",
"If you didn't expect this request, ignore the link — it will expire",
"in 24 hours and the grant will not activate.",
].join("\n");
const html = `
<p><strong>${escapeHtml(input.requesterDisplay)}</strong> has requested a privileged role.</p>
<table style="border-collapse:collapse;font-family:sans-serif">
<tr><td style="padding:4px 8px;color:#666">Role</td><td style="padding:4px 8px"><code>${escapeHtml(input.roleName)}</code></td></tr>
<tr><td style="padding:4px 8px;color:#666">Duration</td><td style="padding:4px 8px">${input.durationHours} hour(s)</td></tr>
<tr><td style="padding:4px 8px;color:#666;vertical-align:top">Justification</td><td style="padding:4px 8px">${escapeHtml(input.justification)}</td></tr>
</table>
<p style="margin-top:24px">
<a href="${escapeHtml(input.approveUrl)}"
style="display:inline-block;padding:10px 18px;background:#0066cc;color:#fff;text-decoration:none;border-radius:4px">
Approve request
</a>
</p>
<p style="color:#888;font-size:12px">If you didn't expect this request, ignore the link — it expires in 24 hours.</p>
`;
await t.sendMail({ from, to: input.to, subject, text, html });
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

45
src/lib/pam-mfa.ts Normal file
View File

@@ -0,0 +1,45 @@
// PAM MFA verification.
//
// v1 stub: accepts any 6-digit code as valid. Real implementation
// will use FreeIPA's `ipa otptoken-find` to locate the user's
// configured OTP token and verify the supplied code against it
// (RFC 6238 TOTP). Tracked in docs/pam-plan.md §11.
//
// Why ship a stub: the plan locks MFA as optional opt-in (decision
// #1). Only `platformadmins` currently requires MFA. Stubbing means
// the request flow can land end-to-end now; we tighten verification
// before opening manual+MFA policies to anyone outside the security
// team.
export interface MfaVerifyInput {
gscsid: string; // requester's FreeIPA uid
token: string; // 6-digit OTP entered in the request form
}
export interface MfaVerifyResult {
ok: boolean;
reason?: string;
/** Structured payload stored in privilege_grants.mfa_evidence */
evidence?: {
method: "totp";
verified_at: string;
stub: true;
};
}
export async function verifyMfa(input: MfaVerifyInput): Promise<MfaVerifyResult> {
const token = input.token?.trim() ?? "";
if (!/^\d{6}$/.test(token)) {
return { ok: false, reason: "invalid_format" };
}
// TODO(pam-mfa): replace with real TOTP verification against
// FreeIPA otptoken (ipa otptoken-find + RFC 6238 step ±1).
return {
ok: true,
evidence: {
method: "totp",
verified_at: new Date().toISOString(),
stub: true,
},
};
}

147
src/lib/pam.ts Normal file
View File

@@ -0,0 +1,147 @@
// PAM domain helpers shared by API routes + UI loaders.
//
// Boundary: anything that talks to admin.privilege_* goes here so
// the route handlers stay thin and the cache invalidation point
// stays in one place.
import { prisma } from "@/database/prisma";
import { invalidateAuthzCache } from "@/lib/authz";
import crypto from "node:crypto";
export type PamEvent =
| "requested"
| "approved-auto"
| "approved-manual"
| "denied"
| "activated"
| "revoked"
| "expired"
| "used";
export interface AuditInput {
event: PamEvent;
gscsid: string;
roleName: string;
grantId?: string | null;
actorGscsid?: string | null;
actorEmail?: string | null;
detail?: Record<string, unknown> | null;
}
export async function recordAudit(input: AuditInput): Promise<void> {
await prisma.privilegeAudit.create({
data: {
event: input.event,
gscsid: input.gscsid,
roleName: input.roleName,
grantId: input.grantId ?? null,
actorGscsid: input.actorGscsid ?? null,
actorEmail: input.actorEmail ?? null,
detail: (input.detail as never) ?? undefined,
},
});
}
export function generateApprovalToken(): string {
return crypto.randomBytes(24).toString("base64url");
}
/**
* Pulls active, non-expired grants for a user. Used by the
* /api/pam/active and the top-bar widget loader.
*/
export async function listActiveGrants(gscsid: string) {
return prisma.privilegeGrant.findMany({
where: {
gscsid,
status: "active",
expiresAt: { gt: new Date() },
},
orderBy: { expiresAt: "asc" },
select: {
id: true,
roleName: true,
grantedAt: true,
expiresAt: true,
justification: true,
},
});
}
/**
* Pending (manual-mode, awaiting email approval) grants for a user.
*/
export async function listPendingGrants(gscsid: string) {
return prisma.privilegeGrant.findMany({
where: { gscsid, status: "pending" },
orderBy: { requestedAt: "desc" },
select: {
id: true,
roleName: true,
requestedAt: true,
expiresAt: true,
justification: true,
},
});
}
/**
* Recent audit entries for a user.
*/
export async function listAudit(gscsid: string, limit = 50) {
return prisma.privilegeAudit.findMany({
where: { gscsid },
orderBy: { ts: "desc" },
take: limit,
select: {
id: true,
ts: true,
event: true,
roleName: true,
grantId: true,
actorGscsid: true,
actorEmail: true,
detail: true,
},
});
}
export async function getPolicy(roleName: string) {
return prisma.privilegePolicy.findUnique({ where: { roleName } });
}
/**
* Revoke an active grant. Caller must already have established
* authorization to do so (self-revoke or admin override).
*/
export async function revokeGrant(
grantId: string,
byGscsid: string,
reason: string | null,
): Promise<void> {
const grant = await prisma.privilegeGrant.findUnique({
where: { id: grantId },
select: { id: true, gscsid: true, roleName: true, status: true },
});
if (!grant) return;
if (grant.status !== "active" && grant.status !== "pending") return;
await prisma.privilegeGrant.update({
where: { id: grantId },
data: {
status: "revoked",
revokedAt: new Date(),
revokedBy: byGscsid,
revokeReason: reason,
},
});
await recordAudit({
event: "revoked",
gscsid: grant.gscsid,
roleName: grant.roleName,
grantId: grant.id,
actorGscsid: byGscsid,
detail: reason ? { reason } : undefined,
});
invalidateAuthzCache(grant.gscsid, grant.roleName);
}

650
src/structure/MyShell.tsx Normal file
View File

@@ -0,0 +1,650 @@
"use client";
import React, { useMemo, useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useSession } from "next-auth/react";
import type { BreadcrumbItem } from "@limitless/ui";
import subBarMenu from "@/config/sub-bar-menu.json";
import topBarMenu from "@/config/top-bar-menu.json";
import siteInformation from "@/config/site-informations.json";
import { LogoutButton } from "@/components/LogoutButton";
type RawMenuItem = {
id: number;
name: string;
url: string;
icon?: string;
key: string;
submenulvl1?: { id?: number; name: string; url: string; key: string; icon?: string }[];
};
type SidebarChild = {
label: string;
href?: string;
key: string;
iconClass?: string;
active?: boolean;
};
type SidebarItem = {
label: string;
href?: string;
iconClass?: string;
active?: boolean;
isOpen?: boolean;
onToggle?: () => void;
children?: SidebarChild[];
key: string;
};
function mapSidebarItems(
menu: RawMenuItem[],
pathname: string,
openSubmenus: Set<string>,
toggleSubmenu: (key: string) => void
): SidebarItem[] {
return menu.map((item) => {
const hasChildren = item.submenulvl1 && item.submenulvl1.length > 0;
const isActive = item.url !== "#"
? pathname.startsWith(item.url)
: item.submenulvl1?.some(child => pathname.startsWith(child.url)) ?? false;
const isOpen = openSubmenus.has(item.key);
return {
label: item.name,
href: item.url === "#" ? undefined : item.url,
iconClass: item.icon,
active: isActive,
isOpen,
onToggle: hasChildren ? () => toggleSubmenu(item.key) : undefined,
key: item.key,
children: hasChildren
? item.submenulvl1!.map((child) => ({
label: child.name,
href: child.url,
key: child.key,
iconClass: child.icon,
active: child.url !== "#" ? pathname.startsWith(child.url) : false,
}))
: undefined,
};
});
}
function buildBreadcrumbs(pathname: string): BreadcrumbItem[] {
const pathData = pathname.split("/").filter(Boolean);
const breadcrumbs: BreadcrumbItem[] = [
{ label: <i className="ph-house" />, href: "/" }
];
if (pathData.length === 0) {
breadcrumbs.push({ label: "My Settings" });
} else if (pathData.length === 1) {
breadcrumbs.push({
label: pathData[0].charAt(0).toUpperCase() + pathData[0].slice(1).toLowerCase(),
});
} else {
breadcrumbs.push({
label: pathData[pathData.length - 2].charAt(0).toUpperCase() +
pathData[pathData.length - 2].slice(1).toLowerCase(),
});
breadcrumbs.push({
label: pathData[pathData.length - 1].charAt(0).toUpperCase() +
pathData[pathData.length - 1].slice(1).toLowerCase(),
});
}
return breadcrumbs;
}
function getPageTitle(pathname: string): string {
const pathData = pathname.split("/").filter(Boolean);
if (pathData.length === 0) return "My Settings";
const lastSegment = pathData[pathData.length - 1];
return lastSegment.charAt(0).toUpperCase() + lastSegment.slice(1).toLowerCase();
}
const SIDEBAR_COLLAPSED_KEY = "my-sidebar-collapsed";
type MyShellProps = {
sidebarMenu: RawMenuItem[];
displayName?: string;
givenName?: string;
children: React.ReactNode;
};
export function MyShell({
sidebarMenu,
children,
}: MyShellProps) {
const pathname = usePathname();
const t = useTranslations();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showMobileSidebar, setShowMobileSidebar] = useState(false);
const [openSubmenus, setOpenSubmenus] = useState<Set<string>>(() => {
const initialOpen = new Set<string>();
sidebarMenu.forEach((item) => {
if (item.submenulvl1?.some(child => pathname.startsWith(child.url))) {
initialOpen.add(item.key);
}
});
return initialOpen;
});
useEffect(() => {
setOpenSubmenus(prev => {
const newSet = new Set(prev);
sidebarMenu.forEach((item) => {
if (item.submenulvl1?.some(child => pathname.startsWith(child.url))) {
newSet.add(item.key);
}
});
return newSet;
});
}, [pathname, sidebarMenu]);
const toggleSubmenu = useCallback((key: string) => {
setOpenSubmenus(prev => {
const newSet = new Set(prev);
if (newSet.has(key)) {
newSet.delete(key);
} else {
newSet.add(key);
}
return newSet;
});
}, []);
useEffect(() => {
const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
if (stored !== null) {
setSidebarCollapsed(stored === "true");
}
}, []);
const isSidebarCollapsed = sidebarCollapsed && !showMobileSidebar;
useEffect(() => {
document.body.classList.toggle("sidebar-xs", isSidebarCollapsed);
return () => {
document.body.classList.remove("sidebar-xs");
};
}, [isSidebarCollapsed]);
useEffect(() => {
document.body.classList.toggle("sidebar-mobile-main", showMobileSidebar);
return () => {
document.body.classList.remove("sidebar-mobile-main");
};
}, [showMobileSidebar]);
const toggleSidebar = () => {
setSidebarCollapsed((prev) => {
const newValue = !prev;
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(newValue));
return newValue;
});
};
const sidebarItems = useMemo(
() => mapSidebarItems(sidebarMenu, pathname, openSubmenus, toggleSubmenu),
[sidebarMenu, pathname, openSubmenus, toggleSubmenu]
);
const breadcrumbs = useMemo(() => buildBreadcrumbs(pathname), [pathname]);
const pageTitle = useMemo(() => getPageTitle(pathname), [pathname]);
const site = Array.isArray(siteInformation) ? siteInformation[0] : null;
return (
<>
<MyNavbar
showMobileSidebar={showMobileSidebar}
setShowMobileSidebar={setShowMobileSidebar}
t={t}
/>
<MySubBar breadcrumbs={breadcrumbs} t={t} />
<MyPageHeader pageTitle={pageTitle} />
<div className="page-content d-flex align-items-start p-3 gap-3" style={{ minHeight: 0 }}>
<MySidebar
sidebarItems={sidebarItems}
sidebarCollapsed={isSidebarCollapsed}
showMobileSidebar={showMobileSidebar}
setShowMobileSidebar={setShowMobileSidebar}
toggleSidebar={toggleSidebar}
pathname={pathname}
/>
<main className="flex-grow-1" style={{ minWidth: 0 }}>
{children}
</main>
</div>
<MyFooter site={site} t={t} />
</>
);
}
function MyNavbar({
showMobileSidebar,
setShowMobileSidebar,
t,
}: {
showMobileSidebar: boolean;
setShowMobileSidebar: (show: boolean) => void;
t: (key: string) => string;
}) {
const { data: session } = useSession();
const [showNavMenu, setShowNavMenu] = useState(false);
const user = session?.user as { displayName?: string; givenName?: string; image?: string } | undefined;
const userName = user?.givenName || user?.displayName || session?.user?.name || "User";
const userInitials = userName
.split(" ")
.map((n: string) => n[0])
.join("")
.slice(0, 2)
.toUpperCase() || "?";
return (
<div className="navbar navbar-dark navbar-expand-lg navbar-static">
<div className="container-fluid">
<div className="d-flex d-lg-none me-2">
<button
type="button"
className="navbar-toggler sidebar-mobile-main-toggle rounded-pill"
onClick={() => setShowMobileSidebar(!showMobileSidebar)}
>
<i className="ph-list"></i>
</button>
</div>
<div className="navbar-brand wmin-200">
<Link href="/" className="d-inline-block">
<img src="https://assets.gosec.cloud/logos/logo.svg" className="h-36px" alt="Logo" />
</Link>
</div>
<ul className="nav flex-row justify-content-end order-1 order-lg-2 ms-auto">
<li
className="nav-item nav-item-dropdown-lg dropdown ms-lg-2"
onMouseLeave={() => setShowNavMenu(false)}
>
<button
type="button"
className="navbar-nav-link align-items-center rounded-pill p-1 border-0 bg-transparent"
onClick={() => setShowNavMenu(!showNavMenu)}
>
<div className="status-indicator-container">
<span
className="w-32px h-32px rounded-pill bg-primary bg-opacity-20 text-primary d-inline-flex align-items-center justify-content-center fw-semibold"
style={{ fontSize: "0.75rem" }}
>
{userInitials}
</span>
<span className="status-indicator bg-success"></span>
</div>
<span className="d-none d-lg-inline-block mx-lg-2">
{userName}
</span>
</button>
<div
className={showNavMenu ? "dropdown-menu dropdown-menu-end show" : "dropdown-menu dropdown-menu-end"}
style={{
position: "absolute",
inset: "0px 0px auto auto",
margin: "0px",
transform: "translate3d(0px, 44px, 0px)",
}}
>
{topBarMenu.map((menuItem) => (
<Link
href={menuItem.url}
className="dropdown-item"
key={menuItem.id}
>
<i className={`${menuItem.icon} me-2`}></i>
{menuItem.name}
</Link>
))}
<LogoutButton label={t("menu.logout")} />
</div>
</li>
</ul>
</div>
</div>
);
}
function MySubBar({
breadcrumbs,
t,
}: {
breadcrumbs: BreadcrumbItem[];
t: (key: string) => string;
}) {
const [showCollapsedSubMenu, setShowCollapsedSubMenu] = useState(false);
const [showSubNavBarMenu, setShowSubNavBarMenu] = useState(false);
return (
<div className="page-header page-header-light shadow">
<div className="page-header-content d-lg-flex">
<div className="d-flex">
<div className="breadcrumb py-2">
{breadcrumbs.map((bc, idx) => (
<span
key={idx}
className={idx === breadcrumbs.length - 1 ? "breadcrumb-item active" : "breadcrumb-item"}
>
{bc.href ? <Link href={bc.href}>{bc.label}</Link> : bc.label}
</span>
))}
</div>
<button
type="button"
className="btn btn-light align-self-center collapsed d-lg-none border-transparent rounded-pill p-0 ms-auto"
onClick={() => setShowCollapsedSubMenu(!showCollapsedSubMenu)}
>
<i className={showCollapsedSubMenu ? "ph-caret-left ph-sm m-1" : "ph-caret-down ph-sm m-1"}></i>
</button>
</div>
<div
className={showCollapsedSubMenu ? "collapse d-lg-block ms-lg-auto show" : "collapse d-lg-block ms-lg-auto"}
>
<div className="d-lg-flex mb-2 mb-lg-0">
<div
className="dropdown"
onMouseLeave={() => setShowSubNavBarMenu(false)}
>
<Link
href="#"
className={showSubNavBarMenu
? "d-flex align-items-center text-body py-2 active"
: "d-flex align-items-center text-body py-2"
}
onClick={() => setShowSubNavBarMenu(!showSubNavBarMenu)}
>
<i className="ph-gear me-2"></i>
<span className="flex-1">{t("menu.settings")}</span>
<i className={showSubNavBarMenu ? "ph-caret-up ms-1" : "ph-caret-down ms-1"}></i>
</Link>
<div
className={showSubNavBarMenu
? "dropdown-menu dropdown-menu-end w-100 w-lg-auto show"
: "dropdown-menu dropdown-menu-end w-100 w-lg-auto"
}
style={{
position: "absolute",
inset: "0px 0px auto auto",
margin: "0px",
transform: "translate3d(0px, 44px, 0px)",
}}
>
{subBarMenu.map((menuItem) => (
<Link
href={menuItem.url}
className="dropdown-item"
key={menuItem.key}
>
<i className={`${menuItem.icon} me-2`}></i>
{menuItem.name}
</Link>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function MyPageHeader({ pageTitle }: { pageTitle: string }) {
return (
<div className="page-header">
<div className="page-header-content d-lg-flex">
<div className="d-flex">
<h4 className="page-title mb-0">
<span className="fw-normal">{pageTitle}</span>
</h4>
</div>
</div>
</div>
);
}
function MySidebar({
sidebarItems,
sidebarCollapsed,
showMobileSidebar,
setShowMobileSidebar,
toggleSidebar,
pathname,
}: {
sidebarItems: SidebarItem[];
sidebarCollapsed: boolean;
showMobileSidebar: boolean;
setShowMobileSidebar: (show: boolean) => void;
toggleSidebar: () => void;
pathname: string;
}) {
const sidebarClasses = [
"sidebar",
"sidebar-light",
"sidebar-main",
"sidebar-expand-lg",
"align-self-start",
sidebarCollapsed ? "sidebar-main-resized" : "",
].filter(Boolean).join(" ");
return (
<div className={sidebarClasses}>
<div className="sidebar-content">
<div className="sidebar-section">
<div className="sidebar-section-body d-flex justify-content-center">
{!sidebarCollapsed && (
<h5 className="sidebar-resize-hide flex-grow-1 my-auto">
Navigation
</h5>
)}
<div>
<button
type="button"
className="btn btn-light btn-icon btn-sm rounded-pill border-transparent sidebar-control sidebar-main-resize d-none d-lg-inline-flex"
onClick={toggleSidebar}
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<i className="ph-arrows-left-right"></i>
</button>
<button
type="button"
className="btn btn-light btn-icon btn-sm rounded-pill border-transparent sidebar-mobile-main-toggle d-lg-none"
onClick={() => setShowMobileSidebar(!showMobileSidebar)}
aria-label="Close sidebar"
>
<i className="ph-x"></i>
</button>
</div>
</div>
</div>
<div className="sidebar-section">
<ul className="nav nav-sidebar" data-nav-type="accordion">
<li className="nav-item-header pt-0">
{!sidebarCollapsed ? (
<div className="text-uppercase fs-sm lh-sm opacity-50 sidebar-resize-hide">
SETTINGS
</div>
) : (
<i className="ph-dots-three sidebar-resize-show"></i>
)}
</li>
{sidebarItems.map((item) => (
<SidebarNavItem
key={item.key}
item={item}
collapsed={sidebarCollapsed}
pathname={pathname}
/>
))}
</ul>
</div>
</div>
</div>
);
}
function SidebarNavItem({
item,
collapsed,
pathname,
}: {
item: SidebarItem;
collapsed: boolean;
pathname: string;
}) {
const hasChildren = item.children && item.children.length > 0;
const isActive = item.active ?? (item.href ? pathname.startsWith(item.href) : false);
const [isHovered, setIsHovered] = useState(false);
const icon = item.iconClass ? <i className={item.iconClass}></i> : null;
if (hasChildren) {
const showFlyout = collapsed && isHovered;
const showInline = !collapsed && item.isOpen;
const navItemClasses = [
"nav-item",
"nav-item-submenu",
showInline ? "nav-item-open" : "",
showFlyout ? "nav-group-sub-visible" : "",
].filter(Boolean).join(" ");
return (
<li
className={navItemClasses}
onMouseEnter={collapsed ? () => setIsHovered(true) : undefined}
onMouseLeave={collapsed ? () => setIsHovered(false) : undefined}
>
<a
href="#"
className={`nav-link ${isActive ? "active" : ""}`}
title={collapsed ? item.label : undefined}
onClick={(event) => {
event.preventDefault();
item.onToggle?.();
}}
onMouseEnter={collapsed ? () => setIsHovered(true) : undefined}
onFocus={collapsed ? () => setIsHovered(true) : undefined}
>
{icon}
{!collapsed && <span>{item.label}</span>}
</a>
<ul
className={
collapsed
? "nav-group-sub nav-group-sub-flyout collapse"
: `nav-group-sub collapse ${item.isOpen ? "show" : ""}`
}
data-submenu-title={item.label}
>
{item.children!.map((child) => (
<li className="nav-item" key={child.key}>
{child.href ? (
<Link
href={child.href}
className={`nav-link ${pathname.startsWith(child.href) ? "active" : ""}`}
>
{child.iconClass && <i className={child.iconClass}></i>}
{child.label}
</Link>
) : (
<span className="nav-link">
{child.iconClass && <i className={child.iconClass}></i>}
{child.label}
</span>
)}
</li>
))}
</ul>
</li>
);
}
return (
<li className="nav-item">
{item.href ? (
<Link
href={item.href}
className={`nav-link ${pathname.startsWith(item.href) ? "active" : ""}`}
title={collapsed ? item.label : undefined}
>
{icon}
{!collapsed && <span>{item.label}</span>}
</Link>
) : (
<span className="nav-link">
{icon}
{!collapsed && <span>{item.label}</span>}
</span>
)}
</li>
);
}
function MyFooter({ site, t }: { site: Record<string, unknown> | null; t: ReturnType<typeof useTranslations> }) {
const siteData = site as { company?: { website?: string }; site?: { name?: string; product?: string } } | null;
return (
<div className="navbar navbar-sm navbar-footer border-top">
<div className="container-fluid">
<span>
&copy; 2023 - {new Date().getFullYear()}{" "}
<a href={siteData?.company?.website}>
{siteData?.site?.name} - {siteData?.site?.product}
</a>
</span>
<ul className="nav">
<li className="nav-item">
<a
href="https://support.gosec.cloud/"
className="navbar-nav-link navbar-nav-link-icon rounded"
target="_blank"
rel="noreferrer"
>
<div className="d-flex align-items-center mx-md-1">
<i className="ph-lifebuoy"></i>
<span className="d-none d-md-inline-block ms-2">Support</span>
</div>
</a>
</li>
<li className="nav-item ms-md-1">
<a
href="https://support.gosec.cloud/support/"
className="navbar-nav-link navbar-nav-link-icon rounded"
target="_blank"
rel="noreferrer"
>
<div className="d-flex align-items-center mx-md-1">
<i className="ph-file-text"></i>
<span className="d-none d-md-inline-block ms-2">
{t("footer.docs")}
</span>
</div>
</a>
</li>
</ul>
</div>
</div>
);
}
export default MyShell;

32135
src/styles/all.min.css vendored Executable file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

1
src/styles/icons/phosphor/styles.min.css vendored Executable file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,187 @@
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 200;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-ExtraLight.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: italic;
font-weight: 200;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-ExtraLightItalic.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 300;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-Light.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: italic;
font-weight: 300;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-LightItalic.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-Regular.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-Italic.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-Medium.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: italic;
font-weight: 500;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-MediumItalic.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-SemiBold.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: italic;
font-weight: 600;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-SemiBoldItalic.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-Bold.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-BoldItalic.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 800;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-ExtraBold.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: italic;
font-weight: 800;
font-display: swap;
src: url("https://assets.gosec.cloud/fonts/jetbrains/JetBrainsMono-ExtraBoldItalic.woff2") format("woff2");
}
body,
html {
font-family: "JetBrains Mono", monospace !important;
}
:root {
--body-font-family: "JetBrains Mono", monospace;
--font-sans-serif: "JetBrains Mono", monospace;
}
.sidebar-main-resized .nav-sidebar > .nav-item > .nav-link,
.sidebar-main-resized .nav-sidebar > .nav-item-submenu > .nav-link {
display: flex;
align-items: center;
justify-content: center !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
.sidebar-main-resized .nav-sidebar > .nav-item > .nav-link i,
.sidebar-main-resized .nav-sidebar > .nav-item-submenu > .nav-link i {
margin-top: 0 !important;
margin-bottom: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
float: none !important;
}
.sidebar-main-resized .nav-item-submenu > .nav-link:after {
content: none !important;
display: none !important;
}
.sidebar-main-resized .nav-item-submenu {
position: relative;
}
.sidebar-main-resized .nav-item-submenu > .nav-group-sub.nav-group-sub-flyout {
position: absolute;
top: calc(var(--nav-link-padding-y) * -1);
left: 100%;
right: auto;
width: var(--ll-sidebar-width, 18.75rem);
background-color: var(--sidebar-bg, #fff);
border: 1px solid var(--border-color-translucent, #dee2e6);
box-shadow: var(--box-shadow, 0 0 1rem rgba(0, 0, 0, 0.15));
border-radius: var(--border-radius, 0.25rem);
padding-left: 0;
list-style: none;
display: none !important;
z-index: 1000;
}
.sidebar-main-resized
.nav-item-submenu
> .nav-group-sub.nav-group-sub-flyout[data-submenu-title]:before {
content: attr(data-submenu-title);
display: block;
padding: var(--nav-link-padding-y) var(--nav-link-padding-x);
padding-bottom: 0;
margin-top: var(--nav-link-padding-y);
opacity: 0.5;
}
.sidebar-main-resized
.nav-item-submenu.nav-group-sub-visible
> .nav-group-sub,
.sidebar-main-resized
.nav-item-submenu:hover
> .nav-group-sub,
.sidebar-main-resized
.nav-item-submenu:focus-within
> .nav-group-sub {
display: block !important;
}

41
tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}