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>
12
.dockerignore
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
43
package.json
Normal 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
@@ -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")
|
||||||
|
}
|
||||||
23
public/locales/de/common.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
public/locales/en/common.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
public/locales/fr/common.json
Normal 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
@@ -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());
|
||||||
16
src/app/(my)/access/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import AccessPageClient from "@/components/pam/AccessPageClient";
|
||||||
|
import { requireAuth } from "@/auth";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Privileged Access — GSC My",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AccessPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ need?: string }>;
|
||||||
|
}) {
|
||||||
|
await requireAuth();
|
||||||
|
const sp = await searchParams;
|
||||||
|
return <AccessPageClient needRole={sp.need ?? null} />;
|
||||||
|
}
|
||||||
133
src/app/(my)/agent/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import https from "node:https";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { getAuthenticatedUser } from "@/auth";
|
||||||
|
import { AgentSettingsForm } from "@/components/settings/AgentSettingsForm";
|
||||||
|
import type { AgentConfig } from "@gsc/chat";
|
||||||
|
|
||||||
|
const OPS_API_URL = process.env.OPS_API_URL || "https://172.17.8.20:8443";
|
||||||
|
const OPS_API_KEY = process.env.OPS_API_KEY || "";
|
||||||
|
|
||||||
|
let _tlsAgent: https.Agent | null = null;
|
||||||
|
function getTlsAgent(): https.Agent {
|
||||||
|
if (!_tlsAgent) {
|
||||||
|
_tlsAgent = new https.Agent({
|
||||||
|
cert: fs.readFileSync("/etc/mcp-certs/client.crt"),
|
||||||
|
key: fs.readFileSync("/etc/mcp-certs/client.key"),
|
||||||
|
ca: fs.readFileSync("/etc/mcp-certs/ca-chain.crt"),
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _tlsAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function opsApiGet(path: string): Promise<string> {
|
||||||
|
const url = new URL(`${OPS_API_URL}${path}`);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.request(
|
||||||
|
{
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port,
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method: "GET",
|
||||||
|
agent: getTlsAgent(),
|
||||||
|
headers: {
|
||||||
|
"X-API-Key": OPS_API_KEY,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on("data", (chunk) => chunks.push(chunk));
|
||||||
|
res.on("end", () => {
|
||||||
|
const body = Buffer.concat(chunks).toString();
|
||||||
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
|
reject(new Error(`HTTP ${res.statusCode}`));
|
||||||
|
} else {
|
||||||
|
resolve(body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on("error", reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAgentConfig(userId?: string, tenantId?: string): Promise<AgentConfig> {
|
||||||
|
if (!userId || !tenantId || !OPS_API_KEY) {
|
||||||
|
return defaultConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await opsApiGet(
|
||||||
|
`/api/v1/agents/me?userId=${userId}&tenantId=${tenantId}`
|
||||||
|
);
|
||||||
|
const envelope = JSON.parse(data);
|
||||||
|
if (envelope.data?.config) {
|
||||||
|
return envelope.data.config as AgentConfig;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to default
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultConfig(): AgentConfig {
|
||||||
|
return {
|
||||||
|
id: "default",
|
||||||
|
userId: "",
|
||||||
|
agentName: "",
|
||||||
|
userName: "",
|
||||||
|
activePersonaId: "default",
|
||||||
|
personas: [
|
||||||
|
{
|
||||||
|
id: "default",
|
||||||
|
name: "Default Assistant",
|
||||||
|
archetype: "Balanced",
|
||||||
|
voiceTone: "Professional",
|
||||||
|
mbti: "INTJ",
|
||||||
|
personality: {
|
||||||
|
openness: 50,
|
||||||
|
conscientiousness: 50,
|
||||||
|
extraversion: 50,
|
||||||
|
agreeableness: 50,
|
||||||
|
neuroticism: 50,
|
||||||
|
},
|
||||||
|
positiveRules: [
|
||||||
|
"Be helpful and concise",
|
||||||
|
"Provide accurate information",
|
||||||
|
"Respect user privacy",
|
||||||
|
],
|
||||||
|
negativeRules: [
|
||||||
|
"Do not share personal data",
|
||||||
|
"Do not make assumptions about intent",
|
||||||
|
],
|
||||||
|
backstory: "",
|
||||||
|
worldBuilding: "",
|
||||||
|
topicalRails: [],
|
||||||
|
defaultModel: "claude-3-5-sonnet",
|
||||||
|
temperature: 0.7,
|
||||||
|
maxTokensPerTurn: 2048,
|
||||||
|
guardrailsConfig: {
|
||||||
|
maxResponseLength: 4000,
|
||||||
|
allowCodeExecution: false,
|
||||||
|
allowExternalLinks: true,
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
memorySettings: {
|
||||||
|
retentionDays: 30,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AgentSettingsPage() {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
const config = await getAgentConfig(user?.gscUserId, user?.tenantId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentSettingsForm initialConfig={config} userGivenName={user?.givenName || ""} />
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/app/(my)/analytics/page.tsx
Normal file
@@ -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
@@ -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
@@ -0,0 +1,48 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{ title: "Profile", description: "View your account information and group memberships", icon: "ph-user-circle", href: "/profile", color: "primary" },
|
||||||
|
{ title: "Settings", description: "Language, timezone, theme, notifications, email and calendar", icon: "ph-gear", href: "/settings", color: "secondary" },
|
||||||
|
{ title: "Security", description: "Two-factor authentication, login sessions, and security log", icon: "ph-shield-check", href: "/security", color: "success" },
|
||||||
|
{ title: "Privacy", description: "Profile visibility, data tracking, and communication preferences", icon: "ph-lock-key", href: "/privacy", color: "warning" },
|
||||||
|
{ title: "Analytics", description: "Login history, app usage, and activity log", icon: "ph-chart-line-up", href: "/analytics", color: "info" },
|
||||||
|
{ title: "Voice", description: "Call forwarding, voicemail, music on hold, and extension settings", icon: "ph-phone", href: "/voice", color: "danger" },
|
||||||
|
{ title: "AI Agent", description: "Configure your personal AI assistant persona and behavior", icon: "ph-robot", href: "/agent", color: "dark" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MyDashboard() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="mb-1">
|
||||||
|
<i className="ph-sliders-horizontal me-2"></i>
|
||||||
|
My Settings
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted mb-0">Manage your personal account settings and preferences.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<div key={section.href} className="col-md-6 col-xl-4 mb-3">
|
||||||
|
<Link href={section.href} className="text-decoration-none">
|
||||||
|
<div className="card h-100 border-0 shadow-sm card-hover">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center mb-3">
|
||||||
|
<div
|
||||||
|
className={`bg-${section.color} bg-opacity-10 text-${section.color} rounded-pill d-flex align-items-center justify-content-center me-3`}
|
||||||
|
style={{ width: "48px", height: "48px" }}
|
||||||
|
>
|
||||||
|
<i className={`${section.icon} fs-4`}></i>
|
||||||
|
</div>
|
||||||
|
<h5 className="mb-0 text-body">{section.title}</h5>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted mb-0 small">{section.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/app/(my)/privacy/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { getAuthenticatedUser } from "@/auth";
|
||||||
|
import { getUserEffectiveSettings } from "@/database/settings";
|
||||||
|
import UserPrivacy from "@/components/account/UserPrivacy";
|
||||||
|
|
||||||
|
export default async function PrivacyPage() {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
let initialSettings: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (user?.gscUserId) {
|
||||||
|
try {
|
||||||
|
const effective = await getUserEffectiveSettings(
|
||||||
|
user.gscUserId,
|
||||||
|
user.tenantId,
|
||||||
|
user.gscCustomerId,
|
||||||
|
["user.privacy", "user.security"]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const cat of Object.values(effective)) {
|
||||||
|
for (const [key, setting] of Object.entries(cat)) {
|
||||||
|
initialSettings[key] = setting.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[privacy page] Failed to load settings:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <UserPrivacy initialSettings={initialSettings} />;
|
||||||
|
}
|
||||||
18
src/app/(my)/profile/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { getAuthenticatedUser } from "@/auth";
|
||||||
|
import UserProfile from "@/components/account/UserProfile";
|
||||||
|
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserProfile
|
||||||
|
displayName={user?.displayName || "User"}
|
||||||
|
givenName={user?.givenName || ""}
|
||||||
|
familyName={user?.familyName || ""}
|
||||||
|
email={user?.email || ""}
|
||||||
|
groups={user?.groups || []}
|
||||||
|
keycloakId={user?.keycloakId || ""}
|
||||||
|
tenantId={user?.tenantId || ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/app/(my)/security/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { getAuthenticatedUser } from "@/auth";
|
||||||
|
import { getUserEffectiveSettings } from "@/database/settings";
|
||||||
|
import { getLoginEvents, getUserSessions, getUserHas2FA } from "@/lib/keycloak";
|
||||||
|
import AccountSecurity from "@/components/account/AccountSecurity";
|
||||||
|
|
||||||
|
export default async function SecurityPage() {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
let loginSessions: Array<{
|
||||||
|
id: string;
|
||||||
|
loginTime: string;
|
||||||
|
ipAddress: string;
|
||||||
|
device: string;
|
||||||
|
location: string;
|
||||||
|
status: "success" | "failed";
|
||||||
|
}> = [];
|
||||||
|
let securityEvents: Array<{
|
||||||
|
id: string;
|
||||||
|
event: string;
|
||||||
|
detail: string;
|
||||||
|
timestamp: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
}> = [];
|
||||||
|
let twoFactorEnabled = false;
|
||||||
|
let loginNotifications = true;
|
||||||
|
let activeSessionCount = 0;
|
||||||
|
|
||||||
|
if (user?.keycloakId) {
|
||||||
|
try {
|
||||||
|
const [events, has2FA, sessions] = await Promise.all([
|
||||||
|
getLoginEvents(user.keycloakId, 20),
|
||||||
|
getUserHas2FA(user.keycloakId),
|
||||||
|
getUserSessions(user.keycloakId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
twoFactorEnabled = has2FA;
|
||||||
|
activeSessionCount = sessions.length;
|
||||||
|
|
||||||
|
// Map Keycloak events to login sessions
|
||||||
|
loginSessions = events.map((e, i) => ({
|
||||||
|
id: String(i),
|
||||||
|
loginTime: new Date(e.time).toISOString(),
|
||||||
|
ipAddress: e.ipAddress || "Unknown",
|
||||||
|
device: e.details?.user_agent
|
||||||
|
? parseUserAgent(e.details.user_agent)
|
||||||
|
: "Unknown",
|
||||||
|
location: "—",
|
||||||
|
status: e.type === "LOGIN" ? "success" as const : "failed" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Map to security events timeline
|
||||||
|
securityEvents = events.slice(0, 10).map((e, i) => ({
|
||||||
|
id: String(i),
|
||||||
|
event: e.type === "LOGIN" ? "Login" : "Failed Login",
|
||||||
|
detail: e.type === "LOGIN"
|
||||||
|
? `Successful login from ${e.ipAddress || "unknown"}`
|
||||||
|
: `Failed login attempt: ${e.error || "invalid credentials"}`,
|
||||||
|
timestamp: new Date(e.time).toISOString(),
|
||||||
|
icon: e.type === "LOGIN" ? "ph-sign-in" : "ph-warning",
|
||||||
|
color: e.type === "LOGIN" ? "success" : "danger",
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[security page] Keycloak fetch error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load loginNotifications preference from DB
|
||||||
|
if (user?.gscUserId) {
|
||||||
|
try {
|
||||||
|
const effective = await getUserEffectiveSettings(
|
||||||
|
user.gscUserId,
|
||||||
|
user.tenantId,
|
||||||
|
user.gscCustomerId,
|
||||||
|
["user.security"]
|
||||||
|
);
|
||||||
|
const secSettings = effective["user.security"];
|
||||||
|
if (secSettings?.loginNotifications) {
|
||||||
|
loginNotifications = secSettings.loginNotifications.value as boolean;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Use default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccountSecurity
|
||||||
|
displayName={user?.displayName || "User"}
|
||||||
|
email={user?.email || ""}
|
||||||
|
loginSessions={loginSessions}
|
||||||
|
securityEvents={securityEvents}
|
||||||
|
twoFactorEnabled={twoFactorEnabled}
|
||||||
|
initialLoginNotifications={loginNotifications}
|
||||||
|
activeSessionCount={activeSessionCount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUserAgent(ua: string): string {
|
||||||
|
if (ua.includes("Chrome") && !ua.includes("Edge")) {
|
||||||
|
if (ua.includes("Windows")) return "Chrome on Windows";
|
||||||
|
if (ua.includes("Mac")) return "Chrome on macOS";
|
||||||
|
if (ua.includes("Linux")) return "Chrome on Linux";
|
||||||
|
return "Chrome";
|
||||||
|
}
|
||||||
|
if (ua.includes("Firefox")) {
|
||||||
|
if (ua.includes("Windows")) return "Firefox on Windows";
|
||||||
|
if (ua.includes("Mac")) return "Firefox on macOS";
|
||||||
|
return "Firefox";
|
||||||
|
}
|
||||||
|
if (ua.includes("Safari") && !ua.includes("Chrome")) {
|
||||||
|
if (ua.includes("iPhone") || ua.includes("iPad")) return "Safari on iOS";
|
||||||
|
return "Safari on macOS";
|
||||||
|
}
|
||||||
|
if (ua.includes("Edge")) return "Edge on Windows";
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
38
src/app/(my)/settings/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { getAuthenticatedUser } from "@/auth";
|
||||||
|
import { getUserEffectiveSettings } from "@/database/settings";
|
||||||
|
import AccountSettings from "@/components/account/AccountSettings";
|
||||||
|
|
||||||
|
const SETTINGS_CATEGORIES = ["user.general", "user.notifications", "user.email", "user.calendar"];
|
||||||
|
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
|
||||||
|
let initialSettings: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (user?.gscUserId) {
|
||||||
|
try {
|
||||||
|
const effective = await getUserEffectiveSettings(
|
||||||
|
user.gscUserId,
|
||||||
|
user.tenantId,
|
||||||
|
user.gscCustomerId,
|
||||||
|
SETTINGS_CATEGORIES
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const cat of Object.values(effective)) {
|
||||||
|
for (const [key, setting] of Object.entries(cat)) {
|
||||||
|
initialSettings[key] = setting.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[settings page] Failed to load settings:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccountSettings
|
||||||
|
displayName={user?.displayName || "User"}
|
||||||
|
email={user?.email || ""}
|
||||||
|
initialSettings={initialSettings}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/app/(my)/voice/page.tsx
Normal file
@@ -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 || ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/app/access-denied/page.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/app/actions/privacy.ts
Normal 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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/actions/security.ts
Normal 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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/actions/settings.ts
Normal 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
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/app/api/agent/config/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
25
src/app/api/auth/logout/route.ts
Normal 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() });
|
||||||
|
}
|
||||||
31
src/app/api/auth/signout/route.ts
Normal 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;
|
||||||
5
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return Response.json({ status: "healthy", service: "gsc-my" });
|
||||||
|
}
|
||||||
14
src/app/api/pam/active/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
151
src/app/api/pam/approve/[token]/route.ts
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
14
src/app/api/pam/audit/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
64
src/app/api/pam/eligible/route.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
208
src/app/api/pam/request/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
38
src/app/api/pam/revoke/[id]/route.ts
Normal 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" });
|
||||||
|
}
|
||||||
14
src/app/auth/keycloak/route.ts
Normal 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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/app/logged-out/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/login/AutoLogin.tsx
Normal 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
@@ -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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/app/signed-out/page.tsx
Normal 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'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
@@ -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;
|
||||||
|
}
|
||||||
19
src/components/LogoutButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
497
src/components/account/AccountSecurity.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
568
src/components/account/AccountSettings.tsx
Normal 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ç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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
504
src/components/account/UserAnalytics.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
475
src/components/account/UserPrivacy.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
339
src/components/account/UserProfile.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
428
src/components/account/UserVoiceSettings.tsx
Normal 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'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
src/components/pam/AccessPageClient.tsx
Normal 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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/pam/ActiveGrantsWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
src/components/pam/RequestModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
868
src/components/settings/AgentSettingsForm.tsx
Normal 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's communication style based on Myers-Briggs personality dimensions.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label fw-medium">Voice & 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'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
@@ -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,
|
||||||
|
};
|
||||||
79
src/config/sidebar-menu.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
11
src/config/site-informations.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"site": {
|
||||||
|
"name": "GoSec Cloud",
|
||||||
|
"product": "My Settings"
|
||||||
|
},
|
||||||
|
"company": {
|
||||||
|
"website": "https://my.gosec.cloud"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
30
src/config/sub-bar-menu.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
16
src/config/top-bar-menu.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 8.8 KiB |
BIN
src/images/layers-2x.png
Executable file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/images/layers.png
Executable file
|
After Width: | Height: | Size: 696 B |
BIN
src/images/login_cover.jpg
Executable file
|
After Width: | Height: | Size: 202 KiB |
BIN
src/images/marker-icon-2x.png
Executable file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/images/marker-icon.png
Executable file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/images/marker-shadow.png
Executable file
|
After Width: | Height: | Size: 618 B |
174
src/lib/authz.ts
Normal 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
@@ -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
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
45
src/lib/pam-mfa.ts
Normal 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
@@ -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
@@ -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>
|
||||||
|
© 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
1079
src/styles/icons/phosphor/fonts/Phosphor.svg
Executable file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
src/styles/icons/phosphor/fonts/Phosphor.ttf
Executable file
BIN
src/styles/icons/phosphor/fonts/Phosphor.woff
Executable file
1
src/styles/icons/phosphor/selection.json
Executable file
1
src/styles/icons/phosphor/styles.min.css
vendored
Executable file
187
src/styles/sidebar-overrides.css
Normal 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
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||