From 616f57bf3d724ab14316a7e5f9f6e03ad5d4f930 Mon Sep 17 00:00:00 2001 From: Bob six Date: Fri, 29 May 2026 19:09:30 +0330 Subject: [PATCH 1/6] feat: add persist query provider for aura added base migration app for aura named core --- .gitignore | 70 +++ CLAUDE.md | 565 ------------------ apps/aura/package.json | 2 + apps/aura/src/app/index.css | 6 +- apps/aura/src/app/providers.tsx | 18 +- .../src/app/routes/_app.subject.$id/route.tsx | 236 +++++--- .../ActivitiesCard/activity-chart/index.tsx | 7 +- .../ProfileEvaluation/connected-card-body.tsx | 4 +- .../ProfileEvaluation/connection-info.tsx | 7 +- .../connection-information.tsx | 57 +- .../ProfileEvaluation/evaluated-card-body.tsx | 4 +- .../Shared/ProfileEvaluation/index.tsx | 9 +- .../evaluations-chart/index.tsx | 7 +- apps/aura/src/lib/queryClient.ts | 3 +- apps/aura/src/lib/queryPersister.ts | 24 + apps/core/.gitignore | 4 + apps/core/aura-ui.d.ts | 180 ++++++ apps/core/bun.lock | 270 +++++++++ apps/core/index.html | 14 + apps/core/package.json | 28 + apps/core/public/global/logo.png | Bin 0 -> 1235594 bytes apps/core/src/components/motions/fade-in.tsx | 17 + apps/core/src/components/motions/scale.tsx | 17 + apps/core/src/index.css | 225 +++++++ apps/core/src/index.tsx | 15 + apps/core/src/router.ts | 112 ++++ apps/core/src/routes/_layout.tsx | 20 + apps/core/src/routes/about.tsx | 14 + apps/core/src/routes/index.tsx | 59 ++ apps/core/src/routes/login.tsx | 3 + apps/core/src/shared/lib/contacts.ts | 12 + apps/core/src/shared/lib/crypto.ts | 41 ++ apps/core/src/shared/lib/env.ts | 4 + apps/core/src/shared/lib/levels.ts | 15 + apps/core/src/shared/lib/number.ts | 11 + apps/core/src/shared/lib/score.ts | 0 apps/core/src/shared/lib/text.ts | 6 + apps/core/src/shared/lib/time.ts | 29 + apps/core/src/shared/lib/urls.ts | 7 + apps/core/src/store/auth.ts | 31 + apps/core/src/store/onboarding.ts | 16 + apps/core/src/store/operations.ts | 15 + apps/core/src/store/preferences.ts | 27 + apps/core/src/store/roles.ts | 16 + apps/core/src/types/evaluations.ts | 31 + apps/core/src/types/notifications.ts | 0 apps/core/tsconfig.json | 22 + apps/core/vite.config.ts | 13 + bun.lock | 257 +++++++- packages/ui/package.json | 2 +- packages/ui/src/components/card.ts | 23 +- packages/ui/src/components/text.ts | 33 + 52 files changed, 1892 insertions(+), 716 deletions(-) delete mode 100644 CLAUDE.md create mode 100644 apps/aura/src/lib/queryPersister.ts create mode 100644 apps/core/.gitignore create mode 100644 apps/core/aura-ui.d.ts create mode 100644 apps/core/bun.lock create mode 100644 apps/core/index.html create mode 100644 apps/core/package.json create mode 100644 apps/core/public/global/logo.png create mode 100644 apps/core/src/components/motions/fade-in.tsx create mode 100644 apps/core/src/components/motions/scale.tsx create mode 100644 apps/core/src/index.css create mode 100644 apps/core/src/index.tsx create mode 100644 apps/core/src/router.ts create mode 100644 apps/core/src/routes/_layout.tsx create mode 100644 apps/core/src/routes/about.tsx create mode 100644 apps/core/src/routes/index.tsx create mode 100644 apps/core/src/routes/login.tsx create mode 100644 apps/core/src/shared/lib/contacts.ts create mode 100644 apps/core/src/shared/lib/crypto.ts create mode 100644 apps/core/src/shared/lib/env.ts create mode 100644 apps/core/src/shared/lib/levels.ts create mode 100644 apps/core/src/shared/lib/number.ts create mode 100644 apps/core/src/shared/lib/score.ts create mode 100644 apps/core/src/shared/lib/text.ts create mode 100644 apps/core/src/shared/lib/time.ts create mode 100644 apps/core/src/shared/lib/urls.ts create mode 100644 apps/core/src/store/auth.ts create mode 100644 apps/core/src/store/onboarding.ts create mode 100644 apps/core/src/store/operations.ts create mode 100644 apps/core/src/store/preferences.ts create mode 100644 apps/core/src/store/roles.ts create mode 100644 apps/core/src/types/evaluations.ts create mode 100644 apps/core/src/types/notifications.ts create mode 100644 apps/core/tsconfig.json create mode 100644 apps/core/vite.config.ts diff --git a/.gitignore b/.gitignore index dd8cf3c..50ac7d7 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,73 @@ yarn-error.log* *.pem .llm/index.md apps/dashboard/aura-adminsdk-private-key.json +apps/core-old/index.html +apps/core-old/package.json +apps/core-old/README.md +apps/core-old/tsconfig.json +apps/core-old/tsconfig.tsbuildinfo +apps/core-old/vite.config.ts +apps/core-old/src/App.tsx +apps/core-old/src/main.ts +apps/core-old/src/vite-env.d.ts +apps/core-old/src/features/auth-backup/queries.ts +apps/core-old/src/features/brightid/queries.ts +apps/core-old/src/features/brightid/recovery/channelService.ts +apps/core-old/src/features/brightid/recovery/constants.ts +apps/core-old/src/features/brightid/recovery/cryptoHelper.ts +apps/core-old/src/features/brightid/recovery/encoding.ts +apps/core-old/src/features/brightid/recovery/recovery.ts +apps/core-old/src/features/brightid/recovery/thunks.ts +apps/core-old/src/features/connections/queries.ts +apps/core-old/src/features/evaluations/composables.ts +apps/core-old/src/features/evaluations/selectors.ts +apps/core-old/src/features/evaluations/types.ts +apps/core-old/src/features/subject-profile/queries.ts +apps/core-old/src/features/subject-profile/ui/connections-list.tsx +apps/core-old/src/features/subject-profile/ui/evaluations-list.tsx +apps/core-old/src/features/subject-profile/ui/outbound-activities.tsx +apps/core-old/src/features/subject-profile/ui/profile-header.tsx +apps/core-old/src/features/subject-profile/ui/profile-overview.tsx +apps/core-old/src/features/subjects-list/composables.ts +apps/core-old/src/features/subjects-list/ui/subject-card.tsx +apps/core-old/src/i18n/index.ts +apps/core-old/src/i18n/locales/en/translation.json +apps/core-old/src/lib/queryClient.ts +apps/core-old/src/lib/queryPersister.ts +apps/core-old/src/router/index.ts +apps/core-old/src/routes/contact-info.tsx +apps/core-old/src/routes/dashboard.tsx +apps/core-old/src/routes/domain-overview.tsx +apps/core-old/src/routes/home.tsx +apps/core-old/src/routes/landing.tsx +apps/core-old/src/routes/notifications.tsx +apps/core-old/src/routes/onboarding-tour.tsx +apps/core-old/src/routes/onboarding.tsx +apps/core-old/src/routes/role-management.tsx +apps/core-old/src/routes/settings.tsx +apps/core-old/src/routes/subject/\[id].tsx +apps/core-old/src/shared/api/index.ts +apps/core-old/src/shared/composables/useDecryptedBackup.ts +apps/core-old/src/shared/lib/contacts.ts +apps/core-old/src/shared/lib/crypto.ts +apps/core-old/src/shared/lib/env.ts +apps/core-old/src/shared/lib/levels.ts +apps/core-old/src/shared/lib/number.ts +apps/core-old/src/shared/lib/score.ts +apps/core-old/src/shared/lib/text.ts +apps/core-old/src/shared/lib/time.ts +apps/core-old/src/shared/lib/urls.ts +apps/core-old/src/shared/types/aura-ui-jsx.d.ts +apps/core-old/src/shared/types/aura.ts +apps/core-old/src/shared/types/dashboard.ts +apps/core-old/src/shared/types/index.ts +apps/core-old/src/shared/types/requirement.ts +apps/core-old/src/shared/ui/app-shell.tsx +apps/core-old/src/stores/auth.ts +apps/core-old/src/stores/index.ts +apps/core-old/src/stores/keypair.ts +apps/core-old/src/stores/operations.ts +apps/core-old/src/stores/recovery.ts +apps/core-old/src/stores/settings.ts +apps/core-old/src/stores/user.ts +apps/core-old/src/styles/index.css diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 9bb1031..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,565 +0,0 @@ -> **LLMs:** Read `.llm/index.md` first for a compact workspace overview (structure, stacks, commands, memory, skills). Return here only when you need specific UI component APIs. - -# Aura UI — Component Reference - -This monorepo ships a Lit-based design system at `packages/ui` (`@aura/ui`). All apps in `./apps` consume it. Use this document when building any UI inside the apps. - -## Setup - -### Lit apps (interface, dashboard, players, docs) - -```ts -// Import the components you need — side-effect imports register the custom elements -import "@aura/ui" // registers all elements - -// Or import selectively -import "@aura/ui/src/components/button" -import "@aura/ui/src/components/card" -``` - -Always wrap the root of your app (or ``) with `` once: - -```html - - - -``` - -### React / Next.js apps (web) - -React wrappers are auto-generated at build time into `packages/ui/src/react-wrappers/`. Import from there: - -```tsx -import { AButton, ACard, AInput } from "@aura/ui/src/react-wrappers" -``` - ---- - -## Theme Provider - -`` injects all CSS custom properties used by every component. **Must be an ancestor of all UI components.** - -```html - -
...
-
-``` - -### CSS custom properties (available everywhere inside the provider) - -| Token | Use | -|---|---| -| `--background` | Page background | -| `--foreground` | Default text color | -| `--primary` / `--primary-foreground` | Brand color pair | -| `--secondary` / `--secondary-foreground` | Secondary color pair | -| `--muted` / `--muted-foreground` | Subdued backgrounds / text | -| `--accent` / `--accent-foreground` | Accent teal pair | -| `--destructive` / `--destructive-foreground` | Error/delete color pair | -| `--card` / `--card-foreground` | Card surface pair | -| `--border` | Borders and dividers | -| `--radius` | Base border radius (`0.75rem`) | -| `--aura-success` | Green success color | -| `--aura-warning` | Amber warning color | -| `--aura-info` | Blue info color | - ---- - -## Components - -### `` - -```html -Click me -Delete -Disabled -``` - -| Prop | Type | Default | Values | -|---|---|---|---| -| `variant` | string | `"default"` | `"default"` `"secondary"` `"ghost"` | -| `color` | string | `"primary"` | `"primary"` `"secondary"` `"success"` `"warning"` `"destructive"` | -| `size` | string | `"md"` | `"sm"` `"md"` `"lg"` | -| `disabled` | boolean | `false` | — | - -No events — wraps a ``, so nest a native ` + + + + + +
+ + Version + 2.1 + + + Powered by: + BrightID + +
+
+ + + ) } diff --git a/apps/core/src/shared/lib/channel.ts b/apps/core/src/shared/lib/channel.ts new file mode 100644 index 0000000..24d9813 --- /dev/null +++ b/apps/core/src/shared/lib/channel.ts @@ -0,0 +1,100 @@ +/* + * Channel service client. + * + * Replaces the old `ChannelAPI` class + apisauce instance. These are plain + * fetch helpers — the recovery flow drives them through TanStack Query + * (mutation for upload, polling query for list/download) instead of calling a + * stateful class. + * + * POST /profile/upload/{channelId} + * GET /profile/list/{channelId} + * GET /profile/download/{channelId}/{dataId} + */ + +const NO_CACHE = { 'Cache-Control': 'no-cache' }; + +async function errorFrom(res: Response): Promise { + try { + const body = await res.json(); + if (body?.error) return body.error as string; + } catch { + // not json + } + return `Request failed with status ${res.status}`; +} + +export interface ChannelTarget { + /** Channel base url, e.g. `/auranode-test/profile` (proxied) */ + channelUrl: string; + channelId: string; +} + +export async function uploadToChannel({ + channelUrl, + channelId, + data, + dataId, + requestedTtl, +}: ChannelTarget & { + data: string; + dataId: string; + requestedTtl?: number; +}): Promise { + const body = JSON.stringify({ + data, + uuid: dataId, + // backend expects seconds + requestedTtl: requestedTtl ? Math.floor(requestedTtl / 1000) : undefined, + }); + const res = await fetch(`${channelUrl}/upload/${channelId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...NO_CACHE }, + body, + }); + if (!res.ok) throw new Error(await errorFrom(res)); +} + +export async function listChannel({ + channelUrl, + channelId, +}: ChannelTarget): Promise { + const res = await fetch(`${channelUrl}/list/${channelId}`, { + headers: NO_CACHE, + }); + if (!res.ok) throw new Error(await errorFrom(res)); + const json = (await res.json()) as { profileIds?: string[] }; + if (!json?.profileIds) { + throw new Error( + `list for channel ${channelId}: unexpected response format`, + ); + } + return json.profileIds; +} + +export async function downloadFromChannel({ + channelUrl, + channelId, + dataId, + deleteAfterDownload, +}: ChannelTarget & { + dataId: string; + deleteAfterDownload?: boolean; +}): Promise { + const res = await fetch(`${channelUrl}/download/${channelId}/${dataId}`, { + headers: NO_CACHE, + }); + if (!res.ok) throw new Error(await errorFrom(res)); + const json = (await res.json()) as { data?: string }; + if (deleteAfterDownload) { + // best-effort cleanup, ignore failures + fetch(`${channelUrl}/${channelId}/${dataId}`, { method: 'DELETE' }).catch( + () => {}, + ); + } + if (!json?.data) { + throw new Error( + `download ${dataId} from channel ${channelId}: unexpected response format`, + ); + } + return json.data; +} diff --git a/apps/core/src/shared/lib/crypto.ts b/apps/core/src/shared/lib/crypto.ts index 19c094d..2accf15 100644 --- a/apps/core/src/shared/lib/crypto.ts +++ b/apps/core/src/shared/lib/crypto.ts @@ -15,19 +15,31 @@ export function decryptData(data: string, password: string) { return CryptoJS.AES.decrypt(data, password).toString(CryptoJS.enc.Utf8); } -export function decryptUserData(encryptedUserData: string, password: string): BrightIdBackup { +export function decryptUserData( + encryptedUserData: string, + password: string, +): BrightIdBackup { return JSON.parse(decryptData(encryptedUserData, password)); } const URL_SAFE_MAP: Record = { '/': '_', '+': '-', '=': '' }; -export const b64ToUrlSafeB64 = (s: string) => s.replace(/[/+=]/g, (c) => URL_SAFE_MAP[c]); +export const b64ToUrlSafeB64 = (s: string) => + s.replace(/[/+=]/g, (c) => URL_SAFE_MAP[c]); export const hash = (data: string) => { const b = CryptoJS.SHA256(data).toString(CryptoJS.enc.Base64); return b64ToUrlSafeB64(b); }; -export const randomWordArray = (size: number) => CryptoJS.lib.WordArray.random(size); +export const randomWordArray = (size: number) => + CryptoJS.lib.WordArray.random(size); + +/** Random url-safe base64 key of `bytes` length — used as the channel AES key. */ +export const urlSafeRandomKey = (bytes = 16): string => + b64ToUrlSafeB64(wordArrayToB64(randomWordArray(bytes))); + +export const uInt8ArrayToB64 = (array: Uint8Array): string => + fromByteArray(array); export const wordArrayToB64 = (wa: CryptoJS.lib.WordArray) => CryptoJS.enc.Base64.stringify(wa); diff --git a/apps/core/src/shared/lib/recovery.ts b/apps/core/src/shared/lib/recovery.ts new file mode 100644 index 0000000..bee4769 --- /dev/null +++ b/apps/core/src/shared/lib/recovery.ts @@ -0,0 +1,94 @@ +import { + downloadFromChannel, + listChannel, + uploadToChannel, +} from "@/shared/lib/channel" +import { b64ToUrlSafeB64, decryptData, hash } from "@/shared/lib/crypto" + +export const RECOVERY_CHANNEL_TTL = 24 * 60 * 60 * 1000 // 1 day +export const CHANNEL_POLL_INTERVAL = 3000 + +const IMPORT_PREFIX = "sig_" +const QR_TYPE_ADD_SUPER_USER_APP = "5" + +export interface RecoveredUser { + id: string + name?: string + password: string +} + +/** Build the channel url embedded in the QR code. */ +export function buildRecoveryChannelQrUrl({ + aesKey, + href, + name, +}: { + aesKey: string + href: string + name?: string +}): string { + const url = new URL(href) + url.searchParams.append("aes", aesKey) + url.searchParams.append("t", QR_TYPE_ADD_SUPER_USER_APP) + if (name) url.searchParams.append("n", name) + url.searchParams.append("p", "false") + return url.href +} + +/** Upload our recovery signing key to the freshly created channel. */ +export async function uploadRecoveryData({ + channelUrl, + aesKey, + publicKey, + timestamp, +}: { + channelUrl: string + aesKey: string + publicKey: string + timestamp: number +}): Promise { + const channelId = hash(aesKey) + const data = JSON.stringify({ signingKey: publicKey, timestamp }) + await uploadToChannel({ + channelUrl, + channelId, + data, + dataId: "data", + requestedTtl: RECOVERY_CHANNEL_TTL, + }) +} + +/** + * Poll the channel once: look for the encrypted user-info the scanner uploaded + * and, if present, decrypt and return it. Returns null until the phone scans. + */ +export async function pollRecoveredUser({ + channelUrl, + channelId, + aesKey, + signingKey, +}: { + channelUrl: string + channelId: string + aesKey: string + signingKey: string +}): Promise { + const dataIds = await listChannel({ channelUrl, channelId }) + + const prefix = `${IMPORT_PREFIX}userinfo_` + const self = b64ToUrlSafeB64(signingKey) + const dataId = dataIds.find( + (id) => + id.startsWith(prefix) && id.replace(prefix, "").split(":")[1] !== self, + ) + if (!dataId) return null + + const encrypted = await downloadFromChannel({ + channelUrl, + channelId, + dataId, + deleteAfterDownload: true, + }) + const info = JSON.parse(decryptData(encrypted, aesKey)) as RecoveredUser + return info +} diff --git a/apps/core/src/shared/types.ts b/apps/core/src/shared/types.ts new file mode 100644 index 0000000..ccd1261 --- /dev/null +++ b/apps/core/src/shared/types.ts @@ -0,0 +1,5 @@ +/** Decrypted BrightID backup blob. Shape is intentionally loose — the backup + * carries arbitrary user data restored from the recovery channel. */ +export interface BrightIdBackup { + [key: string]: unknown; +} diff --git a/apps/core/src/store/recovery.ts b/apps/core/src/store/recovery.ts new file mode 100644 index 0000000..a131081 --- /dev/null +++ b/apps/core/src/store/recovery.ts @@ -0,0 +1,71 @@ +import { createStore } from "solid-js/store" +import { authStore } from "@/store/auth" + +/** + * Recovery / login handshake state. Ephemeral (not persisted) — a fresh AES + * channel is set up each login attempt and discarded once recovered. + * + * The keypair (publicKey/secretKey) and the recovered user (brightId/password) + * are NOT kept here — they live on the auth store. + */ + +export type RecoverStep = "NOT_STARTED" | "INITIALIZING" | "INITIALIZED" | "ERROR" + +/** A keypair older than this is considered stale and regenerated. */ +const KEYPAIR_MAX_AGE = 3 * 24 * 60 * 60 * 1000 // 3 days + +export interface RecoveryState { + recoverStep: RecoverStep + aesKey: string // channel encryption key, embedded in the QR + timestamp: number // when the keypair was generated + channel: { channelId: string; url: { href: string } | null } + errorMessage: string +} + +const initialState = (): RecoveryState => ({ + recoverStep: "NOT_STARTED", + aesKey: "", + timestamp: 0, + channel: { channelId: "", url: null }, + errorMessage: "", +}) + +const [recoveryStore, setRecoveryStore] = createStore( + initialState(), +) + +/** True when the auth keypair is missing or older than KEYPAIR_MAX_AGE. */ +export function isRecoveryKeypairStale(): boolean { + return ( + !recoveryStore.timestamp || + !authStore.publicKey || + recoveryStore.timestamp + KEYPAIR_MAX_AGE < Date.now() + ) +} + +export function initRecovery(aesKey: string, timestamp: number): void { + setRecoveryStore({ + aesKey, + timestamp, + errorMessage: "", + recoverStep: "NOT_STARTED", + }) +} + +export function setRecoverStep(recoverStep: RecoverStep): void { + setRecoveryStore("recoverStep", recoverStep) +} + +export function setRecoveryChannel(channelId: string, href: string): void { + setRecoveryStore("channel", { channelId, url: { href } }) +} + +export function setRecoveryError(errorMessage: string): void { + setRecoveryStore({ errorMessage, recoverStep: "ERROR" }) +} + +export function resetRecovery(): void { + setRecoveryStore(initialState()) +} + +export { recoveryStore, setRecoveryStore } diff --git a/apps/core/vite.config.ts b/apps/core/vite.config.ts index 9beca23..0cb4590 100644 --- a/apps/core/vite.config.ts +++ b/apps/core/vite.config.ts @@ -10,4 +10,23 @@ export default defineConfig({ '@': resolve(__dirname, './src'), }, }, + server: { + // Proxy the recovery channel/profile calls to the BrightID aura nodes to + // avoid CORS during dev. `AURA_NODE_URL_PROXY` (`/auranode[-test]`) is used + // as the channel base url; strip the prefix and forward to the real node. + proxy: { + '/auranode-test': { + target: 'https://aura-test.brightid.org', + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/auranode-test/, ''), + }, + '/auranode': { + target: 'https://aura-node.brightid.org', + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/auranode/, ''), + }, + }, + }, }); diff --git a/bun.lock b/bun.lock index 250ce9c..c7641c2 100644 --- a/bun.lock +++ b/bun.lock @@ -169,13 +169,19 @@ "@aura/ui": "workspace:*", "@solid-primitives/storage": "^4.3.4", "@solidjs/router": "^0.15.3", + "@tanstack/solid-query": "^5.100.14", + "base64-js": "^1.5.1", + "crypto-js": "^4.2.0", "libphonenumber-js": "^1.13.3", + "qrcode": "^1.5.4", "solid-js": "^1.9.5", "solid-motionone": "^1.0.4", "tw-animate-css": "^1.4.0", }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", + "@types/crypto-js": "^4.2.2", + "@types/qrcode": "^1.5.6", "tailwindcss": "^4.1.18", "typescript": "^5.9.2", "vite": "^6.1.0", @@ -456,7 +462,7 @@ }, "packages/ui": { "name": "@aura/ui", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "@lit/react": "^1.0.7", "lit": "^3.3.2", @@ -2020,6 +2026,8 @@ "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="], + "@tanstack/solid-query": ["@tanstack/solid-query@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-eKW5fPWuNjGBjK9To/DNNS2b3HwTvD58T6CZbN6H0HyCjDrBOlH8q4qyJQS9gR9EXflSiivgQK+DUzg3KIHNDw=="], + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="], @@ -5854,6 +5862,8 @@ "@tailwindcss/vite/tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + "@tanstack/solid-query/@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="], + "@tanstack/vue-query/@tanstack/query-core": ["@tanstack/query-core@5.100.11", "", {}, "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw=="], "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], From 2015f68e7b9984eeeb056af2904ddf7695e96a39 Mon Sep 17 00:00:00 2001 From: Bob six Date: Fri, 12 Jun 2026 19:35:08 +0330 Subject: [PATCH 3/6] feat: migrate and implement some routes on the new core app --- apps/core/AGENTS.md | 93 ++++++ apps/core/PORTING_PLAN.md | 273 ++++++++++++++++ apps/core/aura-ui.d.ts | 2 + apps/core/package.json | 11 +- apps/core/src/components/charts/colors.ts | 58 ++++ .../components/charts/evaluations-chart.tsx | 129 ++++++++ .../evaluation/credibility-details.tsx | 197 ++++++++++++ .../components/evaluation/evaluate-modal.tsx | 242 ++++++++++++++ .../components/evaluation/evaluation-card.tsx | 67 ++++ .../evaluation/op-notifications.tsx | 94 ++++++ apps/core/src/components/home/avatar.tsx | 83 +++++ apps/core/src/components/home/home-header.tsx | 84 +++++ .../src/components/home/level-progress.tsx | 215 +++++++++++++ .../components/home/profile-header-card.tsx | 44 +++ .../home/profile-info-performance.tsx | 60 ++++ .../home/profile-not-found-hint.tsx | 58 ++++ .../core/src/components/home/progress-bar.tsx | 16 + .../home/requirements-checklist.tsx | 72 +++++ .../core/src/components/home/subject-card.tsx | 166 ++++++++++ .../components/home/subject-list-controls.tsx | 153 +++++++++ apps/core/src/components/list/list-state.tsx | 27 ++ .../notifications/notifications-checker.tsx | 51 +++ .../src/components/settings/logout-button.tsx | 24 ++ .../src/components/settings/setting-card.tsx | 49 +++ .../src/components/settings/theme-toggle.tsx | 18 ++ .../src/components/settings/version-card.tsx | 22 ++ .../src/components/shared/level-score.tsx | 32 ++ .../components/subject/connection-card.tsx | 36 +++ .../src/components/subject/evidence-help.tsx | 42 +++ .../subject/subject-profile-card.tsx | 61 ++++ apps/core/src/globals.d.ts | 2 + apps/core/src/hooks/use-backup.ts | 35 ++ apps/core/src/hooks/use-evaluate-subject.ts | 31 ++ apps/core/src/hooks/use-levelup-progress.ts | 70 ++++ apps/core/src/hooks/use-my-evaluations.ts | 104 ++++++ apps/core/src/hooks/use-require-session.ts | 14 + .../hooks/use-subject-inbound-evaluations.ts | 170 ++++++++++ .../src/hooks/use-subject-verifications.ts | 26 ++ apps/core/src/hooks/use-subjects-list.ts | 120 +++++++ apps/core/src/hooks/use-view-mode.ts | 57 ++++ apps/core/src/providers.tsx | 88 ++++- apps/core/src/queries/backup.ts | 64 ++++ apps/core/src/queries/connections.ts | 53 +++ apps/core/src/queries/contacts.ts | 31 ++ apps/core/src/queries/evaluations.ts | 55 ++++ apps/core/src/routes/[...404].tsx | 23 ++ apps/core/src/routes/about.tsx | 31 +- apps/core/src/routes/contact-info.tsx | 144 +++++++++ apps/core/src/routes/dashboard.tsx | 106 ++++++ apps/core/src/routes/domain-overview.tsx | 64 ++++ apps/core/src/routes/home/[view]/_layout.tsx | 84 +++++ apps/core/src/routes/home/[view]/index.tsx | 41 +++ apps/core/src/routes/home/[view]/levelup.tsx | 66 ++++ apps/core/src/routes/home/index.tsx | 6 + apps/core/src/routes/index.tsx | 10 +- apps/core/src/routes/login.tsx | 85 ++++- apps/core/src/routes/notifications.tsx | 152 +++++++++ apps/core/src/routes/onboarding.tsx | 111 +++++++ apps/core/src/routes/role-management.tsx | 81 +++++ apps/core/src/routes/settings.tsx | 58 ++++ apps/core/src/routes/subject/[id]/index.tsx | 304 ++++++++++++++++++ apps/core/src/shared/lib/api.ts | 6 + apps/core/src/shared/lib/score.ts | 0 apps/core/src/shared/lib/url-defaults.ts | 7 + apps/core/src/shared/lib/urls.ts | 23 +- apps/core/src/shared/types.ts | 5 - apps/core/src/store/auth.ts | 22 +- apps/core/src/store/contacts.ts | 27 ++ apps/core/src/store/notifications.ts | 51 +++ apps/core/src/store/operations.ts | 35 +- apps/core/src/store/preferences.ts | 30 +- apps/core/vercel.json | 37 +++ apps/core/vite.config.ts | 72 +++-- apps/interface/src/utils/apis/index.ts | 36 +++ bun.lock | 45 ++- packages/domain/package.json | 29 ++ .../lib => packages/domain/src}/channel.ts | 0 .../lib => packages/domain/src}/crypto.ts | 23 +- packages/domain/src/globals.d.ts | 4 + packages/domain/src/http.ts | 71 ++++ packages/domain/src/index.ts | 15 + packages/domain/src/labels.ts | 28 ++ .../lib => packages/domain/src}/levels.ts | 2 +- packages/domain/src/notifications.ts | 234 ++++++++++++++ packages/domain/src/operations.ts | 143 ++++++++ packages/domain/src/passkeys.ts | 176 ++++++++++ .../lib => packages/domain/src}/recovery.ts | 4 +- packages/domain/src/score.ts | 75 +++++ packages/domain/src/types/aura.ts | 116 +++++++ packages/domain/src/types/dashboard.ts | 10 + .../domain}/src/types/evaluations.ts | 0 packages/domain/src/verifications.ts | 34 ++ packages/domain/src/view-mode.ts | 57 ++++ packages/domain/tsconfig.json | 12 + packages/sdk/package.json | 2 +- packages/sdk/src/utils/crypto.ts | 61 +--- packages/ui/src/components/button.ts | 57 +++- packages/ui/src/components/card.ts | 39 ++- packages/ui/src/components/head.ts | 4 +- packages/widgets/src/verification/index.ts | 37 ++- .../widgets/src/verification/success-step.ts | 9 +- 101 files changed, 6243 insertions(+), 160 deletions(-) create mode 100644 apps/core/AGENTS.md create mode 100644 apps/core/PORTING_PLAN.md create mode 100644 apps/core/src/components/charts/colors.ts create mode 100644 apps/core/src/components/charts/evaluations-chart.tsx create mode 100644 apps/core/src/components/evaluation/credibility-details.tsx create mode 100644 apps/core/src/components/evaluation/evaluate-modal.tsx create mode 100644 apps/core/src/components/evaluation/evaluation-card.tsx create mode 100644 apps/core/src/components/evaluation/op-notifications.tsx create mode 100644 apps/core/src/components/home/avatar.tsx create mode 100644 apps/core/src/components/home/home-header.tsx create mode 100644 apps/core/src/components/home/level-progress.tsx create mode 100644 apps/core/src/components/home/profile-header-card.tsx create mode 100644 apps/core/src/components/home/profile-info-performance.tsx create mode 100644 apps/core/src/components/home/profile-not-found-hint.tsx create mode 100644 apps/core/src/components/home/progress-bar.tsx create mode 100644 apps/core/src/components/home/requirements-checklist.tsx create mode 100644 apps/core/src/components/home/subject-card.tsx create mode 100644 apps/core/src/components/home/subject-list-controls.tsx create mode 100644 apps/core/src/components/list/list-state.tsx create mode 100644 apps/core/src/components/notifications/notifications-checker.tsx create mode 100644 apps/core/src/components/settings/logout-button.tsx create mode 100644 apps/core/src/components/settings/setting-card.tsx create mode 100644 apps/core/src/components/settings/theme-toggle.tsx create mode 100644 apps/core/src/components/settings/version-card.tsx create mode 100644 apps/core/src/components/shared/level-score.tsx create mode 100644 apps/core/src/components/subject/connection-card.tsx create mode 100644 apps/core/src/components/subject/evidence-help.tsx create mode 100644 apps/core/src/components/subject/subject-profile-card.tsx create mode 100644 apps/core/src/globals.d.ts create mode 100644 apps/core/src/hooks/use-backup.ts create mode 100644 apps/core/src/hooks/use-evaluate-subject.ts create mode 100644 apps/core/src/hooks/use-levelup-progress.ts create mode 100644 apps/core/src/hooks/use-my-evaluations.ts create mode 100644 apps/core/src/hooks/use-require-session.ts create mode 100644 apps/core/src/hooks/use-subject-inbound-evaluations.ts create mode 100644 apps/core/src/hooks/use-subject-verifications.ts create mode 100644 apps/core/src/hooks/use-subjects-list.ts create mode 100644 apps/core/src/hooks/use-view-mode.ts create mode 100644 apps/core/src/queries/backup.ts create mode 100644 apps/core/src/queries/connections.ts create mode 100644 apps/core/src/queries/contacts.ts create mode 100644 apps/core/src/queries/evaluations.ts create mode 100644 apps/core/src/routes/[...404].tsx create mode 100644 apps/core/src/routes/contact-info.tsx create mode 100644 apps/core/src/routes/dashboard.tsx create mode 100644 apps/core/src/routes/domain-overview.tsx create mode 100644 apps/core/src/routes/home/[view]/_layout.tsx create mode 100644 apps/core/src/routes/home/[view]/index.tsx create mode 100644 apps/core/src/routes/home/[view]/levelup.tsx create mode 100644 apps/core/src/routes/home/index.tsx create mode 100644 apps/core/src/routes/notifications.tsx create mode 100644 apps/core/src/routes/onboarding.tsx create mode 100644 apps/core/src/routes/role-management.tsx create mode 100644 apps/core/src/routes/settings.tsx create mode 100644 apps/core/src/routes/subject/[id]/index.tsx create mode 100644 apps/core/src/shared/lib/api.ts delete mode 100644 apps/core/src/shared/lib/score.ts create mode 100644 apps/core/src/shared/lib/url-defaults.ts delete mode 100644 apps/core/src/shared/types.ts create mode 100644 apps/core/src/store/contacts.ts create mode 100644 apps/core/src/store/notifications.ts create mode 100644 apps/core/vercel.json create mode 100644 packages/domain/package.json rename {apps/core/src/shared/lib => packages/domain/src}/channel.ts (100%) rename {apps/core/src/shared/lib => packages/domain/src}/crypto.ts (74%) create mode 100644 packages/domain/src/globals.d.ts create mode 100644 packages/domain/src/http.ts create mode 100644 packages/domain/src/index.ts create mode 100644 packages/domain/src/labels.ts rename {apps/core/src/shared/lib => packages/domain/src}/levels.ts (90%) create mode 100644 packages/domain/src/notifications.ts create mode 100644 packages/domain/src/operations.ts create mode 100644 packages/domain/src/passkeys.ts rename {apps/core/src/shared/lib => packages/domain/src}/recovery.ts (95%) create mode 100644 packages/domain/src/score.ts create mode 100644 packages/domain/src/types/aura.ts create mode 100644 packages/domain/src/types/dashboard.ts rename {apps/core => packages/domain}/src/types/evaluations.ts (100%) create mode 100644 packages/domain/src/verifications.ts create mode 100644 packages/domain/src/view-mode.ts create mode 100644 packages/domain/tsconfig.json diff --git a/apps/core/AGENTS.md b/apps/core/AGENTS.md new file mode 100644 index 0000000..6923ca1 --- /dev/null +++ b/apps/core/AGENTS.md @@ -0,0 +1,93 @@ +# AGENTS.md — porting the old Aura app into `apps/core` + +You are porting one small slice of the **old React app** (`apps/aura`) into the +**new SolidJS app** (`apps/core`). Pick up a single task from `PORTING_PLAN.md`, +finish it end to end, leave the tree green. Assume a fresh, small context — this +file plus your one task is all you need. + +## The two apps + +| | Old app (source of truth for *behavior*) | New app (where you write code) | +|---|---|---| +| Path | `apps/aura` | `apps/core` | +| Framework | React 18 + React Router | **SolidJS** + `@solidjs/router` | +| State | Zustand + Redux (brightid feature) | `solid-js/store` + `makePersisted` | +| Data | React Query | `@tanstack/solid-query` | +| Styling | Tailwind + shadcn | Tailwind + `@aura/ui` web components (``) | +| Shared logic | inline in app | `packages/domain` (`@aura/domain`) | + +**Port behavior, not code.** The old files tell you *what the feature does*. Do +not transliterate JSX or hooks 1:1 — rewrite idiomatically for Solid and lean on +what `apps/core` and `@aura/domain` already provide. + +## Golden rules + +1. **Reuse before you write.** Before adding anything, grep `apps/core/src`, + `packages/domain/src`, and `packages/ui/src`. Most scoring, crypto, + view-mode, and type logic already lives in `@aura/domain`. Custom UI + primitives (`a-button`, `a-card`, `a-tabs`, `a-dialog`, …) already exist in + `@aura/ui`. If the old app had a 200-line component and the data already + exists in a hook, your port may be 40 lines. + +2. **Simplify as you go — this is expected, not optional.** The old app carries + dead code, commented-out blocks, duplicated helpers, and over-deep prop + drilling. Drop it. If a logic path can be smaller or clearer in Solid + (`createMemo`, `Show`, `Switch`, `For` instead of nested ternaries and + `.map`), do that. Leave the new code smaller than the old. Note in your + summary anything you intentionally dropped. + +3. **Match `apps/core` conventions exactly** (not the old app's): + - Double quotes, no semicolons. 2-space indent. + - `class=` not `className=`. `@/` is `apps/core/src`. + - Reactive values are **accessors** (`foo()`), pass them as `() => x`, never + read-then-pass. Derive with `createMemo`. Effects with `createEffect`. + - Data fetching = a `queryOptions`-style function in `src/queries/` + a + `createQuery` wrapper; components consume a hook in `src/hooks/`. + - Persisted state via `makePersisted`; sensitive/decrypted data stays + memory-only (see `providers.tsx` allowlist). + - Look at a sibling file before writing a new one and mirror its shape. + +4. **Shared logic goes in `@aura/domain`, not the app.** Pure functions + (scoring, parsing, crypto, diffing, formatting that other apps would want) + belong in `packages/domain/src`. UI-only glue stays in `apps/core`. If your + task needs a pure helper, add it to domain and import it. + +5. **Stay in your lane.** Do only your task. If you discover an adjacent gap, + write it down in your summary as a suggested follow-up task — don't expand + scope. Don't touch unrelated files. Don't reformat files you didn't change. + +## Workflow + +1. Read your task in `PORTING_PLAN.md`. Open every old-app file it references. +2. Grep `apps/core` + `@aura/domain` for anything reusable. List what you'll + reuse vs. what you must add. +3. Write the smallest correct Solid implementation. Prefer pure helpers in + `@aura/domain` + a thin component/hook in `apps/core`. +4. Verify (see below). Fix until green. +5. Write a short summary: what you ported, what you reused, what you + deliberately simplified/dropped, and any follow-up tasks you spotted. + +## Verify before you finish + +Run from `apps/core`: + +```bash +bunx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "" +``` + +- **There are pre-existing tsc/biome errors** in the repo (`about.tsx`, + `packages/ui` Lit decorators, etc.). They are not yours. Filter tsc output to + the files you changed and make sure *those* are clean. +- The repo `biome.json` says single-quote/semicolons, but **`apps/core` is + actually written double-quote/no-semicolon** — every existing core file + "fails" biome. Match the existing core files, not biome. +- If your task is visible UI, describe the manual check (route to hit, state to + reach). Use the `run` skill / dev server only if the task says to. + +## Definition of done + +- Feature behaves like the old app (or the documented reduced scope). +- Only your task's files changed; `tsc` clean for them. +- New code is smaller/clearer than the old; no dead or commented-out code. +- Reused `@aura/domain` / `@aura/ui` wherever possible. +- Summary written, including dropped scope + suggested follow-ups. diff --git a/apps/core/PORTING_PLAN.md b/apps/core/PORTING_PLAN.md new file mode 100644 index 0000000..616d9c1 --- /dev/null +++ b/apps/core/PORTING_PLAN.md @@ -0,0 +1,273 @@ +# Porting plan — `apps/aura` (old React) → `apps/core` (new Solid) + +Read `AGENTS.md` first. Each task below is sized for one agent with a small +context window. Tasks list: **goal · old refs · target · scope/done · size · +deps**. Do one, verify, summarize. Tackle milestones top-down; within a +milestone most tasks are independent unless `deps` says otherwise. + +Size key: **S** ≈ <1h / one file · **M** ≈ a few files · **L** = split further +if it feels big (flag it in your summary). + +--- + +## Status snapshot (what already exists in `apps/core`) + +**Done:** login/recovery flow · settings page · `/home/:view` layout + Evaluate +list + Level Up tab · profile header/info cards · level-progress + +requirements-checklist · view-mode/session/backup/subjects-list/my-evaluations/ +verifications hooks · connections+backup queries · auth/preferences/operations/ +roles/recovery/onboarding stores · `@aura/domain` (crypto, http, channel, +recovery, levels, score, verifications, view-mode, types). + +**Also done since:** M0 (subject-card badges, header gating, list controls) · +M1 (evaluate operation builder/mutation/modal/wiring/overlay/notifications) · +M2 T2.1–T2.4 (`/subject/:id` layout + Overview/Evaluations/Connections tabs, +`use-subject-inbound-evaluations` hook, `evaluation-card`) · T4.6 404 +catch-all · passkey login (`@aura/domain/passkeys` + login page). + +**Also done:** T4.1 `/role-management` · T4.4 `/contact-info` (contacts store + +bcrypt-hash mutation) · T4.5 `/onboarding` (URL-stepped, no Swiper) · glass +design pass (a-button `glass` variant in `@aura/ui`, applied with +`a-card[variant=glass]` across home/subject/settings). + +**Also done:** M3 notifications (`@aura/domain/notifications` pure diff · +notifications store + headless checker in providers · `/notifications` route · +unread-badge bell in home header) · T4.2 `/dashboard` · T4.3 `/domain-overview` +(static stats, as planned). + +**Also done:** T2.5 evaluations chart — lightweight CSS-bars +(`components/charts/evaluations-chart.tsx`, no chart lib; drag-zoom dropped), +slotted into the subject Overview tab + credibility-details dialog, plus a +mini impact strip on `subject-card`. + +**Remaining:** M5 shared building blocks (profile pictures, list +states/infinite scroll, global search) — pull in on demand. + +**Also done:** T2.6 credibility-details modal (collapsed to per-role stat rows, +no chart/tabs). + +**M2 leftover:** T2.5 evaluations chart (decision: lightweight bars vs lib — +skipped for now). Next up: M3 notifications. + +--- + +## M0 — Finish Home (low risk, builds on what exists) + +### T0.1 — Subject-card per-row level/score · S +- **Old:** `apps/aura/src/components/evaluation/SubjectCard.tsx` +- **Target:** `apps/core/src/components/home/subject-card.tsx` (remove its `NOTE:`) +- **Done:** show the subject's level/score badge per row using + `useSubjectVerifications(() => id, category)`. Keep the row lean — one small + verifications read, no chart. If per-row fetching is heavy, note it and cap. + +### T0.2 — Home-header role gating · S +- **Old:** role-locked tabs logic in `apps/aura` header/home + `useLevelupProgress` +- **Target:** `apps/core/src/components/home/home-header.tsx` (remove its `NOTE:`) +- **Done:** Trainer/Manager view buttons disabled until unlocked + (`useLevelupProgress` per category). Mirror the Level Up tab gating already in + `home/[view]/_layout.tsx`. + +### T0.3 — Subjects list: filter + sort · M +- **Old:** `SubjectListControls.tsx`, `FiltersModal.tsx`, `SortsModal.tsx`, + `useFilterAndSort.ts`, `useFilters.ts`, `useSorts.ts` +- **Target:** `apps/core/src/hooks/use-subjects-list.ts` (remove its `NOTE:`) + + a `subject-list-controls.tsx` using ``/`` +- **Done:** filter by level/connection-state + sort by recency/score/name over + the existing list. Keep filter/sort state local (signal), no new store unless + needed. Collapse the old three-modal sprawl into one controls component. + +--- + +## M1 — Evaluation flow (the core feature; do in order) + +### T1.1 — Evaluate operation builder in `@aura/domain` · M +- **Old:** `apps/aura/src/features/brightid/utils/operations.ts`, + `cryptoHelper.ts`, `api/brightId.ts` (the "add evaluation/connection op" + paths), `EvaluateOperation` shape in `@aura/domain/types/evaluations` +- **Target:** new `packages/domain/src/operations.ts` (pure) +- **Done:** pure functions to build + sign an evaluate operation and post it to + the node (reuse `@aura/domain/crypto` + `http`). No Solid/React. Return the + op + hash so the store can track it. Unit-testable. +- **deps:** none — foundation for the rest of M1. + +### T1.2 — `useEvaluateSubject` hook · M +- **Old:** `apps/aura/src/hooks/useEvaluateSubject.ts` +- **Target:** `apps/core/src/hooks/use-evaluate-subject.ts` + + `apps/core/src/queries/` mutation +- **Done:** `createMutation` that calls T1.1, writes the pending op into the + `operations` store, invalidates the right queries on settle. Optimistic. +- **deps:** T1.1. + +### T1.3 — Evaluation modal UI · M +- **Old:** `EvaluationFlow.tsx`, `EvaluateModalBody.tsx` (rate ±, confidence) +- **Target:** `apps/core/src/components/evaluation/evaluate-modal.tsx` using + `` +- **Done:** pick positive/negative + confidence, submit via T1.2, show + pending/error. Drop the old multi-step wizard if a single form is clearer — + note the decision. +- **deps:** T1.2. + +### T1.4 — Wire Evaluate button · S +- **Target:** `subject-card.tsx` + `home/[view]/index.tsx` +- **Done:** the existing `onEvaluate` opens the T1.3 modal for that subject. +- **deps:** T1.3. + +### T1.5 — Optimistic overlay in `use-my-evaluations` · S +- **Old:** `selectEvaluateOperations` overlay referenced in core's `NOTE:` +- **Target:** `apps/core/src/hooks/use-my-evaluations.ts` (remove its `NOTE:`) +- **Done:** merge pending ops from the `operations` store over server ratings so + a just-submitted evaluation shows immediately. +- **deps:** T1.1 (op shape). + +### T1.6 — Operation status notifications · S +- **Old:** `EvaluationOpNotifications.tsx` +- **Target:** `apps/core/src/components/evaluation/op-notifications.tsx` using + `@aura/ui` toaster (`toast`, see `providers.tsx`) +- **Done:** toast on op applied/failed by watching the `operations` store. +- **deps:** T1.2. + +--- + +## M2 — Subject detail `/subject/:id` + +### T2.1 — Route scaffold + header · M +- **Old:** `apps/aura/src/app/routes/_app.subject.$id/route.tsx` + `header`, + `profile-tabs` +- **Target:** `apps/core/src/routes/subject/[id]/_layout.tsx` + `index.tsx` +- **Done:** route resolves `:id`, header shows name/avatar/level/score (reuse + `profile-header-card` patterns + `useSubjectName`), three-tab nav (Overview / + Evaluations / Connections) mirroring `home/[view]/_layout.tsx`. + +### T2.2 — Overview tab · M +- **Old:** `components/Shared/ProfileOverview/index.tsx` +- **Target:** `apps/core/src/routes/subject/[id]/index.tsx` (overview) +- **Done:** score/level/impact summary. **Reuse** `level-progress.tsx` and the + Evaluations summary already built on the Level Up page. **Skip** the echarts + chart for now (separate task T2.5). +- **deps:** T2.1. + +### T2.3 — Evaluations tab · M +- **Old:** `ProfileEvaluation/*`, `useSubjectInboundEvaluations.ts` +- **Target:** `apps/core/src/routes/subject/[id]/evaluations.tsx` + an + `evaluation-card.tsx` component +- **Done:** port `useSubjectInboundEvaluations` to core (hook + query), list + inbound evaluations as compact cards (evaluator, rating, confidence, time). + Flatten the old 8-file `ProfileEvaluation` tree into one card component. +- **deps:** T2.1. + +### T2.4 — Connections tab · M +- **Old:** `useSubjectInboundConnections.ts`, `connection-level.tsx`, + `connection-list-search` +- **Target:** `apps/core/src/routes/subject/[id]/connections.tsx` +- **Done:** port the inbound-connections hook + a connections list. +- **deps:** T2.1. + +### T2.5 — Evaluations chart (decision task) · M/L +- **Old:** `ProfileOverview/evaluations-chart/`, `utils/chart.ts` (recharts) +- **Target:** `apps/core/src/components/charts/` +- **Done:** FIRST decide & note: port a chart lib for Solid, or render a simple + CSS/SVG impact bar list. Default to the lightweight option unless the user + asks for parity. Then implement and slot into T2.2. +- **deps:** T2.2. + +### T2.6 — Credibility details modal · S +- **Old:** `CredibilityDetailsModal.tsx` +- **Target:** `apps/core/src/components/evaluation/credibility-details.tsx` +- **Done:** `` breakdown for a single evaluation; opened from T2.3/chart. +- **deps:** T2.3. + +--- + +## M3 — Notifications + +### T3.1 — Notification-diff logic in `@aura/domain` · M +- **Old:** `apps/aura/src/store/notifications.store.ts` (LEVEL_CHANGE=1, + SCORE_CHANGE_PERCENTAGE=10 thresholds, alert building), + `components/notifications/notifications-checker.tsx` +- **Target:** new `packages/domain/src/notifications.ts` (pure) +- **Done:** pure function: (prev backup/verifications, next) → alerts[]. No I/O. + +### T3.2 — Notifications store + poller · M +- **Target:** `apps/core/src/store/notifications.ts` + a checker that runs T3.1 + on backup refresh +- **Done:** persist read/unread; produce alerts via T3.1. Reuse existing query + refetch cadence, no new polling stack if backup query already refetches. +- **deps:** T3.1. + +### T3.3 — `/notifications` route · M +- **Old:** `_app.notifications/route.tsx` (tabs by EvaluationCategory, cards) +- **Target:** `apps/core/src/routes/notifications.tsx` +- **Done:** list alerts grouped by category, mark-as-read, link to subject. +- **deps:** T3.2. + +--- + +## M4 — Remaining routes (mostly independent, S/M each) + +### T4.1 — `/role-management` · M +- **Old:** `_app.role-management/*` (player/trainer/manager role cards, + `score-and-level.tsx`) +- **Target:** `apps/core/src/routes/role-management.tsx` +- **Done:** three role cards showing level/score/qualification via + `useSubjectVerifications` per category + `useLevelupProgress`. Collapse the + three near-identical card components into one parameterized card. + +### T4.2 — `/dashboard` · M +- **Old:** `_app.dashboard/*` (domain + preferred-view cards, link grid, + role-select modal) +- **Target:** `apps/core/src/routes/dashboard.tsx` +- **Done:** preferred-view selector (writes view to store/route) + link grid. + +### T4.3 — `/domain-overview` · S +- **Old:** `_app.domain-overview/route.tsx` (currently hardcoded stats) +- **Target:** `apps/core/src/routes/domain-overview.tsx` +- **Done:** straight port of the static stat cards; wire real counts only if a + domain query already exists — else keep static and note it. + +### T4.4 — `/contact-info` · M +- **Old:** `_app.contact-info/route.tsx`, `contacts.store.ts`, + `useStoreNewContactMutation` +- **Target:** `apps/core/src/routes/contact-info.tsx` + contacts store/mutation +- **Done:** list hashed contacts (masked), add dialog with validation. Reuse + `apps/core/src/shared/lib/contacts.ts` (`normalizeContactValue`) + domain + `hash`. Settings already links here. + +### T4.5 — `/onboarding` carousel · M +- **Old:** `_landing.onboarding/*` (4 steps, swiper) +- **Target:** `apps/core/src/routes/onboarding.tsx` +- **Done:** 4-step tutorial; track step in URL; mark seen in `onboarding` + store. Use a CSS/scroll carousel — don't pull in Swiper. + +### T4.6 — 404 catch-all · S +- **Old:** `_app.$/route.tsx` +- **Target:** `apps/core/src/routes/[...404].tsx` +- **Done:** not-found page with home link. (Router already supports catch-all.) + +--- + +## M5 — Shared building blocks (do when a task above needs them) + +### T5.1 — Profile pictures · S +- **Old:** `BrightIdProfilePicture.tsx`, `GravatarProfilePicture.tsx`, + `profile.ts` gravatar query +- **Target:** `apps/core/src/components/avatar` (extend existing `avatar.tsx`) +- **Done:** real image with initials fallback (already have the fallback). + +### T5.2 — List states + infinite scroll · S +- **Old:** `EmptyAndLoadingStates/*`, `InfiniteScrollLocal.tsx` +- **Target:** `apps/core/src/components/list/` +- **Done:** shared empty/loading/``-based incremental list helpers; replace + ad-hoc "Loading…/No items" divs in existing routes. + +### T5.3 — Global search modal · M +- **Old:** `GlobalSearchModal.tsx` +- **Target:** `apps/core/src/components/search/` +- **Done:** search subjects/contacts, jump to `/subject/:id`. + +--- + +## Suggested order for a single worker + +M0 (warm-up, ships value) → **M1 in order** (unblocks the app's core verb) → +M2 → M3 → M4 (parallelizable) → M5 (pull in on demand). Pull an M5 task forward +whenever an earlier task is blocked on a shared piece. diff --git a/apps/core/aura-ui.d.ts b/apps/core/aura-ui.d.ts index 744dcd6..6056861 100644 --- a/apps/core/aura-ui.d.ts +++ b/apps/core/aura-ui.d.ts @@ -57,11 +57,13 @@ declare module "solid-js" { color?: ButtonColors type?: "button" | "submit" | "reset" disabled?: boolean + selected?: boolean } // ── Card ───────────────────────────────────────────────────────────── "a-card": CEProps & { variant?: "default" | "glass" + interactive?: boolean } // ── Badge ──────────────────────────────────────────────────────────── diff --git a/apps/core/package.json b/apps/core/package.json index bbc1eb2..cf9bd29 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -1,6 +1,6 @@ { "name": "@aura/core", - "version": "0.0.1", + "version": "2.1.0", "type": "module", "license": "MIT", "scripts": { @@ -10,13 +10,16 @@ "check-types": "tsc --noEmit" }, "dependencies": { + "@aura/domain": "workspace:*", "@aura/ui": "workspace:*", "@solid-primitives/storage": "^4.3.4", "@solidjs/router": "^0.15.3", + "@tanstack/query-async-storage-persister": "^5.100.14", "@tanstack/solid-query": "^5.100.14", - "base64-js": "^1.5.1", - "crypto-js": "^4.2.0", + "@tanstack/solid-query-persist-client": "^5.100.14", + "bcryptjs": "^3.0.3", "libphonenumber-js": "^1.13.3", + "localforage": "^1.10.0", "qrcode": "^1.5.4", "solid-js": "^1.9.5", "solid-motionone": "^1.0.4", @@ -24,7 +27,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", - "@types/crypto-js": "^4.2.2", + "@types/bcryptjs": "^3.0.0", "@types/qrcode": "^1.5.6", "tailwindcss": "^4.1.18", "typescript": "^5.9.2", diff --git a/apps/core/src/components/charts/colors.ts b/apps/core/src/components/charts/colors.ts new file mode 100644 index 0000000..861c4af --- /dev/null +++ b/apps/core/src/components/charts/colors.ts @@ -0,0 +1,58 @@ +/** + * Bar palettes from the old app (`constants/chart.ts`), keyed by the signed + * confidence (-4…4): green/red shades for other evaluators, purple for the + * logged-in user's own bar, orange for the focused subject's bar. + */ +type ColorMap = Record + +const valueColorMap: ColorMap = { + "-4": "#924848", + "-3": "#DA6A6A", + "-2": "#EE9D9D", + "-1": "#F5BFBF", + "1": "#D5ECDA", + "2": "#B4E6C0", + "3": "#72BF83", + "4": "#5B9969", +} + +const userRatingColorMap: ColorMap = { + "-4": "#D9C7F9", + "-3": "#C2A8F3", + "-2": "#AC89ED", + "-1": "#956AE6", + "1": "#8341DE", + "2": "#6C34B3", + "3": "#572988", + "4": "#451F6D", +} + +const subjectRatingColorMap: ColorMap = { + "-4": "#FAD7A0", + "-3": "#F8C471", + "-2": "#F5B041", + "-1": "#F39C12", + "1": "#E67E22", + "2": "#CA6A1A", + "3": "#AF5714", + "4": "#91450F", +} + +const clampSigned = (value: number) => + Math.max(-4, Math.min(4, Math.round(value))) || 1 + +/** Color for one evaluation bar (old `getBarChartColor`). */ +export function barColor( + signedConfidence: number, + evaluator: string, + authBrightId?: string, + focusedSubjectId?: string, +): string { + const map = + evaluator === authBrightId + ? userRatingColorMap + : evaluator === focusedSubjectId + ? subjectRatingColorMap + : valueColorMap + return map[String(clampSigned(signedConfidence))] +} diff --git a/apps/core/src/components/charts/evaluations-chart.tsx b/apps/core/src/components/charts/evaluations-chart.tsx new file mode 100644 index 0000000..af4f602 --- /dev/null +++ b/apps/core/src/components/charts/evaluations-chart.tsx @@ -0,0 +1,129 @@ +import { createMemo, For, Show } from "solid-js" +import { barColor } from "@/components/charts/colors" +import Avatar from "@/components/home/avatar" +import { useNameResolver } from "@/hooks/use-backup" +import { authStore } from "@/store/auth" +import type { AuraImpactRaw } from "@aura/domain/types/aura" + +// Old chart showed evaluator pictures for up to 20 bars, 16–40px by count. +const MAX_AVATARS = 20 +const avatarSize = (count: number) => + Math.round(40 - ((40 - 16) / (MAX_AVATARS - 1)) * (count - 1)) + +interface Bar { + id: string + name: string + /** Share of the subject's total absolute impact, signed (-100..100). */ + percent: number + /** Bar height inside its half of the plot, normalized to the max (0..100). */ + height: number + color: string +} + +/** + * Evaluation-impacts chart (plan T2.5) — the old recharts chart rebuilt as + * plain CSS bars: one bar per evaluator, height = share of the subject's + * total impact, negatives below the baseline. Bars use the old confidence + * palettes (purple = you, orange = the focused subject) and carry the + * evaluator's picture underneath when the list is small enough. Click opens + * the evaluator. The old drag-zoom is dropped — the strip scrolls instead. + */ +export default function EvaluationsChart(props: { + impacts: () => AuraImpactRaw[] | null + onBarClick?: (evaluatorId: string) => void + /** Highlighted subject (orange palette), e.g. the profile being viewed. */ + focusedSubjectId?: () => string +}) { + const nameOf = useNameResolver() + + const bars = createMemo(() => { + const impacts = (props.impacts() ?? []).filter((i) => i.impact !== 0) + const total = impacts.reduce((sum, i) => sum + Math.abs(i.impact), 0) + if (!total) return [] + const maxAbs = Math.max(...impacts.map((i) => Math.abs(i.impact))) + return [...impacts] + .sort((a, b) => a.impact - b.impact) + .map((i) => ({ + id: i.evaluator, + name: nameOf(i.evaluator), + percent: (i.impact / total) * 100, + height: Math.max(8, Math.round((Math.abs(i.impact) / maxAbs) * 100)), + color: barColor( + i.confidence * Math.sign(i.impact), + i.evaluator, + authStore.user?.brightId, + props.focusedSubjectId?.(), + ), + })) + }) + + const showAvatars = () => bars().length <= MAX_AVATARS + + return ( + 0} + fallback={ +
+ No evaluation impacts yet. +
+ } + > +
+ + {(bar) => ( + + )} + +
+
+ ) +} diff --git a/apps/core/src/components/evaluation/credibility-details.tsx b/apps/core/src/components/evaluation/credibility-details.tsx new file mode 100644 index 0000000..732d473 --- /dev/null +++ b/apps/core/src/components/evaluation/credibility-details.tsx @@ -0,0 +1,197 @@ +import { A, useNavigate } from "@solidjs/router" +import { createEffect, createMemo, createSignal, For, Show } from "solid-js" +import type { DialogElement } from "@aura/ui" +import EvaluationsChart from "@/components/charts/evaluations-chart" +import EvaluateModal from "@/components/evaluation/evaluate-modal" +import Avatar from "@/components/home/avatar" +import ProgressBar from "@/components/home/progress-bar" +import LevelScore from "@/components/shared/level-score" +import { useSubjectName } from "@/hooks/use-backup" +import { useMyRating } from "@/hooks/use-my-evaluations" +import { useSubjectInboundEvaluations } from "@/hooks/use-subject-inbound-evaluations" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { authStore } from "@/store/auth" +import { categoryLabel, confidenceLabel } from "@aura/domain/labels" +import { calculateUserScorePercentage, impactShare } from "@aura/domain/score" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +/** One role's stats panel: standing, your evaluation, impacts chart. */ +function RoleStats(props: { + subjectId: () => string + category: EvaluationCategory + onNavigate: (subjectId: string) => void + onEvaluate: (category: EvaluationCategory) => void +}) { + const v = useSubjectVerifications(props.subjectId, () => props.category) + const inbound = useSubjectInboundEvaluations( + props.subjectId, + () => props.category, + ) + const progress = createMemo(() => + calculateUserScorePercentage(props.category, v.auraScore() ?? 0), + ) + + // My evaluation of this subject in this role + its share of total impact. + const my = useMyRating(props.subjectId, () => props.category) + const myImpactPercent = createMemo(() => + impactShare(v.auraImpacts(), authStore.user?.brightId), + ) + const isSelf = () => props.subjectId() === authStore.user?.brightId + + return ( +
+ +

+ Evaluations:{" "} + + {inbound.evaluations()?.length ?? "…"} + {" "} + ({inbound.positiveCount() ?? "…"} pos / {inbound.negativeCount() ?? "…"}{" "} + neg) +

+ = 0}> + + + +
+ + Your evaluation:{" "} + + 0 ? "text-aura-success" : "text-destructive"}`} + > + {(my.rating()! > 0 ? "+" : "") + my.rating()}{" "} + {confidenceLabel(my.rating()!)} + + + · {myImpactPercent()}% impact + + + + + props.onEvaluate(props.category)} + > + + Edit + + + +
+ + v.auraImpacts()} + onBarClick={props.onNavigate} + /> +
+ ) +} + +/** + * Credibility breakdown dialog for a subject (usually an evaluator tapped in + * a list). Ported from the old `CredibilityDetailsModal`: one tab per role + * the subject has unlocked (Subject always), each with standing, your + * evaluation (editable inline) and the impacts chart. + */ +export default function CredibilityDetails(props: { + subjectId: () => string | null + onClose: () => void +}) { + let dialog: DialogElement | undefined + const navigate = useNavigate() + + const id = () => props.subjectId() ?? "" + const name = useSubjectName(id) + + // Role tabs are gated like the old modal: Subject always, others once the + // subject has a level in them. All four reads share one profile query. + const roleChecks = Object.values(EvaluationCategory).map((category) => ({ + category, + v: useSubjectVerifications(id, () => category), + })) + const authorizedRoles = createMemo(() => + roleChecks + .filter( + ({ category, v }) => + category === EvaluationCategory.SUBJECT || (v.auraLevel() ?? 0) > 0, + ) + .map(({ category }) => category), + ) + + // Inline evaluate (old modal's pencil / "Evaluate Now"): track which role + // is being evaluated; null keeps the nested dialog closed. + const [evaluatingCategory, setEvaluatingCategory] = + createSignal(null) + + // Chart bar tap: close the dialog and jump to that evaluator (old behavior). + const goTo = (subjectId: string) => { + props.onClose() + navigate(`/subject/${subjectId}`) + } + + createEffect(() => { + if (props.subjectId()) dialog?.show() + else dialog?.hide() + }) + + const onOpenChange = (e: CustomEvent<{ open: boolean }>) => { + if (!e.detail.open && props.subjectId()) props.onClose() + } + + return ( + <> + +
+
+ +

{name()}

+
+ + {/* keyed: remount the tabs (and reset the active one) per subject */} + + + + {(category) => ( + {categoryLabel[category]} + )} + + + {(category) => ( + + + + )} + + + + + props.onClose()} + > + + View profile + + +
+
+ + {/* Sibling overlay — a-dialog only projects its named slots, and a + later fixed overlay stacks above the credibility dialog. */} + (evaluatingCategory() ? id() : null)} + category={() => evaluatingCategory() ?? EvaluationCategory.SUBJECT} + onClose={() => setEvaluatingCategory(null)} + /> + + ) +} diff --git a/apps/core/src/components/evaluation/evaluate-modal.tsx b/apps/core/src/components/evaluation/evaluate-modal.tsx new file mode 100644 index 0000000..541c687 --- /dev/null +++ b/apps/core/src/components/evaluation/evaluate-modal.tsx @@ -0,0 +1,242 @@ +import { createEffect, createSignal, For, Show } from "solid-js" +import { toast } from "@aura/ui" +import type { DialogElement } from "@aura/ui" +import { useEvaluateSubject } from "@/hooks/use-evaluate-subject" +import { useMyRating } from "@/hooks/use-my-evaluations" +import { useSubjectName } from "@/hooks/use-backup" +import { useViewMode } from "@/hooks/use-view-mode" +import { + categoryLabel, + CONFIDENCE_LABELS, + confidenceLabel, +} from "@aura/domain/labels" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +const CONFIDENCE_VALUES = Object.keys(CONFIDENCE_LABELS).map(Number) + +/** Question + expression copy ported from the old app's translations. */ +const QUESTIONS: Record = { + [EvaluationCategory.SUBJECT]: + "Is this the account of {name} that should be Aura verified?", + [EvaluationCategory.PLAYER]: + "Does this Player accurately and honestly evaluate Subjects in the BrightID domain?", + [EvaluationCategory.TRAINER]: + "Does this Trainer accurately and honestly evaluate Players in the BrightID domain?", + [EvaluationCategory.MANAGER]: + "Does this Manager accurately and honestly evaluate Managers and Trainers in the BrightID domain?", +} + +const EXPRESSIONS: Record = { + [EvaluationCategory.SUBJECT]: { + positive: "… this account should be verified.", + negative: "… this account should not be verified.", + }, + [EvaluationCategory.PLAYER]: { + positive: "… this Player accurately and honestly evaluates Subjects.", + negative: "… this Player does not accurately and honestly evaluate Subjects.", + }, + [EvaluationCategory.TRAINER]: { + positive: "… this Trainer accurately and honestly evaluates Players.", + negative: "… this Trainer does not accurately and honestly evaluate Players.", + }, + [EvaluationCategory.MANAGER]: { + positive: "… this Manager accurately and honestly evaluates Managers and Trainers.", + negative: "… this Manager does not accurately and honestly evaluate Managers and Trainers.", + }, +} + +/** + * Controlled evaluation dialog, ported from the old `EvaluateModalBody`: + * category question, Yes/No, confidence, the "what this means" expression + * line, and — when an evaluation already exists — Update plus a two-step + * Remove (confidence-0 operation, which the node treats as deletion). + */ +export default function EvaluateModal(props: { + subjectId: () => string | null + onClose: () => void + /** Override the evaluation category (defaults to the current view's). */ + category?: () => EvaluationCategory +}) { + let dialog: DialogElement | undefined + + const vm = useViewMode() + const category = () => props.category?.() ?? vm.currentEvaluationCategory() + + const [isYes, setIsYes] = createSignal(true) + const [confidence, setConfidence] = createSignal(1) + const [onDelete, setOnDelete] = createSignal(false) + + const { submitEvaluation, isPending } = useEvaluateSubject(category) + const name = useSubjectName(() => props.subjectId() ?? "") + // Existing rating for this subject in this category — seeds the form. + const existing = useMyRating(() => props.subjectId() ?? "", category) + + createEffect(() => { + const id = props.subjectId() + if (id) { + const prev = existing.rating() + setIsYes(prev === undefined ? true : prev > 0) + setConfidence(prev === undefined ? 1 : Math.abs(prev) || 1) + setOnDelete(false) + dialog?.show() + } else { + dialog?.hide() + } + }) + + const close = () => props.onClose() + + const onOpenChange = (e: CustomEvent<{ open: boolean }>) => { + if (!e.detail.open && props.subjectId()) close() + } + + const fail = (e: unknown) => + toast.error("Error", { + description: + "Failed to submit evaluation" + + (e instanceof Error ? `: ${e.message}` : String(e)), + duration: 5000, + }) + + const submit = async () => { + const id = props.subjectId() + if (!id || isPending()) return + try { + await submitEvaluation(id, isYes() ? confidence() : -confidence()) + close() + } catch (e) { + fail(e) + } + } + + // Two-step remove: first tap arms it, second submits a confidence-0 op. + const remove = async () => { + const id = props.subjectId() + if (!id || isPending()) return + if (!onDelete()) return setOnDelete(true) + try { + await submitEvaluation(id, 0) + close() + } catch (e) { + fail(e) + } + } + + return ( + +
+
+ + Evaluate {name()} as a{" "} + {categoryLabel[category()]} in the{" "} + BrightID domain + +

+ {QUESTIONS[category()].replace("{name}", name())} +

+
+ +
+ setIsYes(true)} + > + Yes + + setIsYes(false)} + > + No + +
+ +
+ How confident are you? +
+ + {(value) => ( + setConfidence(value)} + > + {confidenceLabel(value)} + + )} + +
+

+ I'm{" "} + + {confidenceLabel(confidence())} + {" "} + confident that{" "} + {EXPRESSIONS[category()][isYes() ? "positive" : "negative"]} +

+
+ + + dialog?.hide()} + > + Cancel + + + + Sending… + + +
+ } + > +
+ + + Sending… + + + + + + Remove + + +
+ + +
+ ) +} diff --git a/apps/core/src/components/evaluation/evaluation-card.tsx b/apps/core/src/components/evaluation/evaluation-card.tsx new file mode 100644 index 0000000..dd40159 --- /dev/null +++ b/apps/core/src/components/evaluation/evaluation-card.tsx @@ -0,0 +1,67 @@ +import { Show } from "solid-js" +import Avatar from "@/components/home/avatar" +import { compactFormat } from "@/shared/lib/number" +import { formatDuration } from "@/shared/lib/time" +import type { InboundEvaluation } from "@/hooks/use-subject-inbound-evaluations" +import { confidenceLabel } from "@aura/domain/labels" + +/** + * Inbound-evaluation row: evaluator (photo, name, level), signed rating chip + * with confidence, impact share and age. + */ +export default function EvaluationCard(props: { + evaluation: InboundEvaluation + onClick?: (evaluatorId: string) => void +}) { + const e = () => props.evaluation + const positive = () => e().rating > 0 + + return ( + props.onClick?.(e().evaluatorId)} + > +
+ +
+

{e().name}

+

+ + Level {e().level} + · + + + + {compactFormat(e().score!)} + + · + + {formatDuration(e().timestamp)} +

+
+
+ +
+ + + {(positive() ? "+" : "") + e().rating} + {confidenceLabel(e().confidence)} + + + + {e().impactPercent}% of impact + + +
+
+ ) +} diff --git a/apps/core/src/components/evaluation/op-notifications.tsx b/apps/core/src/components/evaluation/op-notifications.tsx new file mode 100644 index 0000000..cdc75b8 --- /dev/null +++ b/apps/core/src/components/evaluation/op-notifications.tsx @@ -0,0 +1,94 @@ +import { fetchOperationState } from "@aura/domain/operations" +import type { OperationState } from "@aura/domain/types/evaluations" +import { toast } from "@aura/ui" +import { createEffect, createMemo, onCleanup } from "solid-js" +import { useBackup } from "@/hooks/use-backup" +import { NODE_API_BASE } from "@/shared/lib/api" +import { operationsStore, setOperationState } from "@/store/operations" + +/** How often to re-check pending ops against the node (old OPERATION_TRACE_TIME). */ +const POLL_INTERVAL_MS = 5_000 + +/** States that are still in flight and worth polling. */ +const PENDING_STATES: ReadonlySet = new Set([ + "INIT", + "SENT", + "UNKNOWN", +]) + +/** + * Always-on poller + toaster for submitted evaluations (T1.6). While any op in + * the operations store is still pending (`INIT`/`SENT`/`UNKNOWN`), it polls each + * one's state on an interval, writes changes back to the store, and toasts once + * per op when it settles to `APPLIED` (success) or `FAILED`/`EXPIRED` (error). + * + * Renders nothing — mount it once at app root. Pairs with T1.5's optimistic + * overlay: the overlay shows the rating immediately at `INIT`; once `APPLIED` + * the invalidated server reads take over and the overlay becomes a no-op. + * + * Replaces the old `EvaluationOpNotifications` rich UI with plain toasts. + */ +export default function OpNotifications() { + const backup = useBackup() + + // Resolve a subject's display name from the backup, falling back to a short id. + const subjectName = (id: string): string => { + const data = backup.data + if (!data) return id.slice(0, 7) + const info = + id === data.userData.id + ? data.userData + : data.connections.find((c) => c.id === id) + return info?.name ?? id.slice(0, 7) + } + + // Hashes we've already toasted on, so each transition fires exactly once. + const notified = new Set() + + const pendingHashes = createMemo(() => + Object.values(operationsStore.byHash) + .filter((op) => PENDING_STATES.has(op.state)) + .map((op) => op.hash), + ) + + const poll = async () => { + for (const hash of pendingHashes()) { + const op = operationsStore.byHash[hash] + if (!op) continue + let next: OperationState + try { + next = await fetchOperationState(NODE_API_BASE, hash) + } catch { + // Transient node/transport error — leave the op pending and retry next tick. + continue + } + if (next === op.state) continue + + setOperationState(hash, next) + + if (notified.has(hash)) continue + if (next === "APPLIED") { + notified.add(hash) + toast.success("Evaluation submitted", { + description: subjectName(op.evaluated), + }) + } else if (next === "FAILED" || next === "EXPIRED") { + notified.add(hash) + toast.error("Evaluation failed", { + description: subjectName(op.evaluated), + }) + } + } + } + + // Run the interval only while there are pending ops; tear it down otherwise so + // we never poll an idle node. The effect re-runs when pending count changes. + createEffect(() => { + if (pendingHashes().length === 0) return + const timer = setInterval(poll, POLL_INTERVAL_MS) + void poll() + onCleanup(() => clearInterval(timer)) + }) + + return null +} diff --git a/apps/core/src/components/home/avatar.tsx b/apps/core/src/components/home/avatar.tsx new file mode 100644 index 0000000..3e478f3 --- /dev/null +++ b/apps/core/src/components/home/avatar.tsx @@ -0,0 +1,83 @@ +import { createMemo, type JSX, Show } from "solid-js" +import { createProfilePhotoQuery } from "@/queries/backup" +import { authStore } from "@/store/auth" +import { hash } from "@aura/domain/crypto" + +/** + * Avatar with the real BrightID profile photo when available (decrypted from + * the recovery backup — needs a password session, so passkey users get the + * initials fallback). Pass `subjectId` to enable the photo and the hover + * preview: hovering shows the enlarged image, like the old + * `BrightIdProfilePicture`. + */ +export default function Avatar(props: { + name: string + subjectId?: string + /** Disable the enlarged hover preview (e.g. inside dialogs). */ + noHover?: boolean + class?: string + style?: JSX.CSSProperties +}) { + const initial = () => (props.name?.trim()?.[0] ?? "?").toUpperCase() + + // Photos are stored per-connection in the logged-in user's backup. + const authKey = createMemo(() => { + const user = authStore.user + return user?.brightId && user.password + ? hash(user.brightId + user.password) + : "" + }) + const photo = createProfilePhotoQuery( + authKey, + () => props.subjectId ?? "", + () => authStore.user?.password ?? "", + ) + // Backup photos are data URIs; tolerate raw base64 just in case. + const src = () => { + const data = photo.data + if (!data) return undefined + return data.startsWith("data:") ? data : `data:image/jpeg;base64,${data}` + } + + const Circle = () => ( + + {initial()} + + } + > + {props.name} + + ) + + return ( + }> + + + + +
+ {props.name} +

+ {props.name} +

+
+
+
+ ) +} diff --git a/apps/core/src/components/home/home-header.tsx b/apps/core/src/components/home/home-header.tsx new file mode 100644 index 0000000..90efac2 --- /dev/null +++ b/apps/core/src/components/home/home-header.tsx @@ -0,0 +1,84 @@ +import { A } from "@solidjs/router" +import { For, Show } from "solid-js" +import { useLevelupProgress } from "@/hooks/use-levelup-progress" +import { useViewMode } from "@/hooks/use-view-mode" +import { unreadCount } from "@/store/notifications" +import { viewModeToSlug, viewModeToString } from "@aura/domain/view-mode" +import { PreferredView } from "@aura/domain/types/dashboard" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +/** Views in the switcher, paired with the category that gates each one. */ +const VIEWS: { view: PreferredView; gate: EvaluationCategory | null }[] = [ + { view: PreferredView.PLAYER, gate: null }, + { view: PreferredView.TRAINER, gate: EvaluationCategory.TRAINER }, + { + view: PreferredView.MANAGER_EVALUATING_TRAINER, + gate: EvaluationCategory.MANAGER, + }, +] + +/** + * Home header with role-view switcher — each view is its own route + * (`/home/:view`). Trainer/Manager stay disabled until unlocked, mirroring the + * "Level Up" tab gating in `home/[view]/_layout.tsx`. + */ +export default function HomeHeader() { + const vm = useViewMode() + const trainerProgress = useLevelupProgress(() => EvaluationCategory.TRAINER) + const managerProgress = useLevelupProgress(() => EvaluationCategory.MANAGER) + + const isUnlocked = (gate: EvaluationCategory | null) => { + if (gate === EvaluationCategory.TRAINER) return trainerProgress().isUnlocked + if (gate === EvaluationCategory.MANAGER) return managerProgress().isUnlocked + return true + } + + return ( +
+ Home +
+ + + + + 0}> + + {unreadCount()} + + + + + {({ view, gate }) => ( + + {viewModeToString[view]} + + } + > + + + {viewModeToString[view]} + + + + )} + +
+
+ ) +} diff --git a/apps/core/src/components/home/level-progress.tsx b/apps/core/src/components/home/level-progress.tsx new file mode 100644 index 0000000..696eccb --- /dev/null +++ b/apps/core/src/components/home/level-progress.tsx @@ -0,0 +1,215 @@ +import { createMemo, createSignal, Match, Show, Switch } from "solid-js" +import RequirementsChecklist, { + type LevelRequirement, +} from "@/components/home/requirements-checklist" +import { useMyEvaluations } from "@/hooks/use-my-evaluations" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { useViewMode } from "@/hooks/use-view-mode" +import { compactFormat } from "@/shared/lib/number" +import { playerLevelPoints } from "@aura/domain/levels" +import { + calculateRemainingScoreToNextLevel, + calculateUserScorePercentage, +} from "@aura/domain/score" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +const PLAYER_EVALUATION_MINIMUM_COUNT_BEFORE_TRAINING = 3 + +type NextLevelStatus = { + isPassed: boolean + reason: string + progress?: number + checklists?: LevelRequirement[] +} + +/** Progress toward the subject's next level in the evaluator category. */ +export default function LevelProgress(props: { subjectId: string }) { + const vm = useViewMode() + const category = vm.currentRoleEvaluatorEvaluationCategory + const [showRequirements, setShowRequirements] = createSignal(false) + + const v = useSubjectVerifications(() => props.subjectId, category) + const { myRatings } = useMyEvaluations() + + const remainingScore = createMemo(() => + calculateRemainingScoreToNextLevel(category(), v.auraScore() ?? 0), + ) + + const nextLevel = createMemo(() => { + if (category() !== EvaluationCategory.PLAYER) { + return { isPassed: true, reason: "" } + } + + const impacts = v.auraImpacts() ?? [] + const medium = impacts.filter((i) => i.confidence > 1) + const high = impacts.filter((i) => i.confidence > 2) + const level = v.auraLevel() + const score = v.auraScore() ?? 0 + + if (level === 1) { + return { + progress: 0, + isPassed: medium.filter((i) => (i.level ?? 0) >= 1).length > 0, + reason: "1 Medium+ confidence evaluation from one level 1+ trainer", + checklists: [ + { title: "Score: 2M+", requirement: playerLevelPoints[2] - score }, + { + title: "1 Medium+ confidence evaluation from one level 1+ trainer", + requirement: 1 - medium.filter((i) => (i.level ?? 0) >= 1).length, + }, + ], + } + } + + if (level === 2) { + const hasOneHigh = high.filter((i) => (i.level ?? 0) >= 2).length >= 1 + const hasTwoMedium = medium.filter((i) => (i.level ?? 0) >= 2).length >= 2 + return { + isPassed: hasOneHigh || hasTwoMedium, + reason: "2 Medium+ confidence evaluation from level 2+ trainers", + progress: (medium.length + high.length) / 3, + checklists: [ + { title: "Score: 3M+", requirement: playerLevelPoints[3] - score }, + { + OR: [ + { + title: "2 Medium+ confidence evaluation from level 2+ trainers", + requirement: 2 - medium.length, + }, + { + title: "1 High+ confidence evaluation from one level 2+ trainer", + requirement: 1 - high.length, + }, + ], + }, + ], + } + } + + return { isPassed: true, reason: "" } + }) + + const ratingsToBeDone = createMemo(() => { + const r = myRatings() + if (!r) return undefined + return Math.max( + PLAYER_EVALUATION_MINIMUM_COUNT_BEFORE_TRAINING - + r.filter((x) => Number(x.rating)).length, + 0, + ) + }) + + const progressPercentage = createMemo(() => { + const progress = nextLevel().progress + if (progress) return Math.floor(progress * 100) + const todo = ratingsToBeDone() + if (!todo) return 0 + return Math.floor( + ((PLAYER_EVALUATION_MINIMUM_COUNT_BEFORE_TRAINING - todo) * 100) / + PLAYER_EVALUATION_MINIMUM_COUNT_BEFORE_TRAINING, + ) + }) + + const levelPercentage = createMemo(() => + calculateUserScorePercentage(category(), v.auraScore() ?? 0), + ) + + const barWidth = createMemo(() => + remainingScore() > 0 || nextLevel().progress + ? progressPercentage() + : levelPercentage(), + ) + + const nextLevelLabel = () => `Level ${(v.auraLevel() ?? 0) + 1}` + + return ( + +
+ +
+
Level
+
+ {v.auraLevel() ?? "-"} +
+
+
+ +
+
+ +

+ {nextLevel().reason} + + + +

+ to + + {nextLevelLabel()} + + + } + > + + ... + + + + {compactFormat(Math.abs(v.auraScore() ?? 0))} + + to + Level 1 + + 0}> + + {compactFormat(remainingScore())} + + to + + {nextLevelLabel()} + + + + + You've reached the maximum level! 🎉 + + +
+
+ +
+ + score:{" "} + + {compactFormat(v.auraScore() ?? 0)} + + +
+
+
+
+ + + {(checklists) => ( +
+
+ Requirements for the next level +
+ +
+ )} +
+ + ) +} diff --git a/apps/core/src/components/home/profile-header-card.tsx b/apps/core/src/components/home/profile-header-card.tsx new file mode 100644 index 0000000..864d56f --- /dev/null +++ b/apps/core/src/components/home/profile-header-card.tsx @@ -0,0 +1,44 @@ +import { createMemo, Show } from "solid-js" +import Avatar from "@/components/home/avatar" +import ProgressBar from "@/components/home/progress-bar" +import LevelScore from "@/components/shared/level-score" +import { useSubjectName } from "@/hooks/use-backup" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { useViewMode } from "@/hooks/use-view-mode" +import { calculateUserScorePercentage } from "@aura/domain/score" + +export default function ProfileHeaderCard(props: { subjectId: string }) { + const name = useSubjectName(() => props.subjectId) + const vm = useViewMode() + const v = useSubjectVerifications( + () => props.subjectId, + vm.currentRoleEvaluatorEvaluationCategory, + ) + const progress = createMemo(() => + calculateUserScorePercentage( + vm.currentRoleEvaluatorEvaluationCategory(), + v.auraScore() ?? 0, + ), + ) + + return ( + +
+ +
+

+ {name()} +

+ + = 0} fallback={😈}> + + +
+
+
+ ) +} diff --git a/apps/core/src/components/home/profile-info-performance.tsx b/apps/core/src/components/home/profile-info-performance.tsx new file mode 100644 index 0000000..2c45ef6 --- /dev/null +++ b/apps/core/src/components/home/profile-info-performance.tsx @@ -0,0 +1,60 @@ +import { Show } from "solid-js" +import ProgressBar from "@/components/home/progress-bar" +import { useLevelupProgress } from "@/hooks/use-levelup-progress" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { useViewMode } from "@/hooks/use-view-mode" +import { compactFormat } from "@/shared/lib/number" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +/** Level-up progress toward the evaluator role. Hidden once unlocked. */ +export default function ProfileInfoPerformance(props: { subjectId: string }) { + const vm = useViewMode() + const category = vm.currentRoleEvaluatorEvaluationCategory + const v = useSubjectVerifications(() => props.subjectId, category) + const progress = useLevelupProgress(category) + + const percent = () => Math.max(0, Math.min(100, progress().percent)) + + return ( + + +
+
+ + + +
+

+ Level Up locked +

+

{progress().reason}

+
+
+ + Score{" "} + + {compactFormat(v.auraScore() ?? 0)} + + +
+ +
+ + + {Math.round(percent())}% + +
+ + +

+ Evaluate subjects below to make progress. +

+
+
+
+ ) +} diff --git a/apps/core/src/components/home/profile-not-found-hint.tsx b/apps/core/src/components/home/profile-not-found-hint.tsx new file mode 100644 index 0000000..328ebbf --- /dev/null +++ b/apps/core/src/components/home/profile-not-found-hint.tsx @@ -0,0 +1,58 @@ +import { toast } from "@aura/ui" + +/** + * Shown when a profile/connections lookup 404s: the aura node only creates a + * profile once the user receives their first evaluation, so nudge them to + * share the profile (or, for other subjects, to evaluate them). + */ +export default function ProfileNotFoundHint(props: { + subjectId: string + /** Whether the missing profile is the logged-in user's own. */ + self?: boolean +}) { + const share = async () => { + const url = `${window.location.origin}/subject/${props.subjectId}` + try { + if (navigator.share) { + await navigator.share({ title: "Aura profile", url }) + return + } + await navigator.clipboard?.writeText(url) + toast.success("Profile link copied", { + description: "Send it to a connection and ask for an evaluation.", + }) + } catch { + /* user dismissed the share sheet */ + } + } + + return ( + +
+ +

+ {props.self ? "No Aura profile yet" : "This subject has no profile yet"} +

+
+ + {props.self + ? "Your profile is created when another player evaluates you. " + + "Share your profile link with a connection and ask for your first evaluation." + : "A profile is created once someone evaluates this subject — " + + "evaluate them or share their profile to get them started."} + + + Share profile + +
+ ) +} diff --git a/apps/core/src/components/home/progress-bar.tsx b/apps/core/src/components/home/progress-bar.tsx new file mode 100644 index 0000000..403cc6c --- /dev/null +++ b/apps/core/src/components/home/progress-bar.tsx @@ -0,0 +1,16 @@ +export default function ProgressBar(props: { + percentage: number + class?: string +}) { + const pct = () => Math.max(0, Math.min(100, props.percentage)) + return ( +
+
+
+ ) +} diff --git a/apps/core/src/components/home/requirements-checklist.tsx b/apps/core/src/components/home/requirements-checklist.tsx new file mode 100644 index 0000000..c6948a8 --- /dev/null +++ b/apps/core/src/components/home/requirements-checklist.tsx @@ -0,0 +1,72 @@ +import { For, Show } from "solid-js" +import { compactFormat } from "@/shared/lib/number" + +export type BaseRequirement = { title: string; requirement: number } +export type LevelRequirement = + | BaseRequirement + | { OR?: BaseRequirement[]; AND?: BaseRequirement[] } + +const isBase = (item: LevelRequirement): item is BaseRequirement => + "title" in item + +/** One checklist row, or a nested AND/OR group of rows. */ +function ChecklistItem(props: { item: LevelRequirement }) { + return ( + + } + > + {(base) => ( +
+ + + {base().title}{" "} + 0}> + ({compactFormat(base().requirement)} more needed) + + +
+ )} +
+ ) +} + +function GroupItem(props: { + item: { OR?: BaseRequirement[]; AND?: BaseRequirement[] } +}) { + const children = () => props.item.AND ?? props.item.OR ?? [] + const separator = () => (props.item.AND ? "(AND)" : "(OR)") + return ( +
+ + {(child, index) => ( + <> + + +

{separator()}

+
+ + )} +
+
+ ) +} + +/** Requirements for reaching the next level, with met/unmet indicators. */ +export default function RequirementsChecklist(props: { + checklists: LevelRequirement[] +}) { + return ( +
+ {(item) => } +
+ ) +} diff --git a/apps/core/src/components/home/subject-card.tsx b/apps/core/src/components/home/subject-card.tsx new file mode 100644 index 0000000..5df17b1 --- /dev/null +++ b/apps/core/src/components/home/subject-card.tsx @@ -0,0 +1,166 @@ +import { useNavigate } from "@solidjs/router" +import { createMemo, For, Show } from "solid-js" +import Avatar from "@/components/home/avatar" +import ProgressBar from "@/components/home/progress-bar" +import LevelScore from "@/components/shared/level-score" +import { useMyRating } from "@/hooks/use-my-evaluations" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { useViewMode } from "@/hooks/use-view-mode" +import { toTitleCase } from "@/shared/lib/text" +import { authStore } from "@/store/auth" +import { confidenceLabel } from "@aura/domain/labels" +import { calculateUserScorePercentage, impactShare } from "@aura/domain/score" +import type { + BrightIdBackupConnection, + ConnectionLevel, +} from "@aura/domain/types/aura" + +/** Old app's connection-level palette — used as a status dot. */ +const LEVEL_COLORS: Record = { + reported: "#FF4B31", + suspicious: "#FF7831", + recovery: "#FFA131", + "already known": "#FFC585", + "just met": "#FFB85C", + "aura only": "#FFE8D4", +} + +const MAX_IMPACT_BARS = 8 + +/** + * Subject row, ported from the old `SubjectCard`: avatar, name, level/score, + * score progress, connection-level + your-evaluation status chips and a small + * impact strip (lightweight CSS bars instead of the old echarts mini chart). + */ +export default function SubjectCard(props: { + connection: BrightIdBackupConnection + onEvaluate?: (id: string) => void +}) { + const navigate = useNavigate() + const subjectId = () => props.connection.id + const name = () => props.connection.name || subjectId().slice(0, 7) + + const vm = useViewMode() + const v = useSubjectVerifications(subjectId, vm.currentEvaluationCategory) + const my = useMyRating(subjectId, vm.currentEvaluationCategory) + + const progress = createMemo(() => + calculateUserScorePercentage( + vm.currentEvaluationCategory(), + v.auraScore() ?? 0, + ), + ) + const myImpactPercent = createMemo(() => + impactShare(v.auraImpacts(), authStore.user?.brightId), + ) + + /** Top evaluations by |impact|, normalized for the mini bar strip. */ + const impactBars = createMemo(() => { + const impacts = (v.auraImpacts() ?? []) + .filter((i) => i.impact !== 0) + .sort((a, b) => Math.abs(b.impact) - Math.abs(a.impact)) + .slice(0, MAX_IMPACT_BARS) + const max = Math.max(...impacts.map((i) => Math.abs(i.impact)), 1) + return impacts.map((i) => ({ + positive: i.impact > 0, + height: Math.max(15, Math.round((Math.abs(i.impact) / max) * 100)), + })) + }) + + return ( + navigate(`/subject/${subjectId()}`)} + > +
+
+ +
+

{name()}

+ + = 0} fallback={😈}> + + +
+
+ + {/* Connection level + my evaluation status */} +
+ + + {toTitleCase(props.connection.level)} + + + + {(rating) => ( + 0 + ? "bg-aura-success/15 text-aura-success" + : "bg-destructive/15 text-destructive" + }`} + > + 0 ? "thumbs-up" : "thumbs-down"} /> + {(rating() > 0 ? "+" : "") + rating()} + } + > + {confidenceLabel(rating())} + + · {myImpactPercent()}% + + + + )} + +
+
+ +
+ 0}> +
+ + {(bar) => ( + + )} + +
+
+ { + e.stopPropagation() + props.onEvaluate?.(subjectId()) + }} + > + + Edit + + +
+
+ ) +} diff --git a/apps/core/src/components/home/subject-list-controls.tsx b/apps/core/src/components/home/subject-list-controls.tsx new file mode 100644 index 0000000..ca8d869 --- /dev/null +++ b/apps/core/src/components/home/subject-list-controls.tsx @@ -0,0 +1,153 @@ +import { For, Show } from "solid-js" +import type { useSubjectsList } from "@/hooks/use-subjects-list" +import type { SubjectRatedState, SubjectSort } from "@/hooks/use-subjects-list" +import { toTitleCase } from "@/shared/lib/text" +import type { ConnectionLevel } from "@aura/domain/types/aura" +import type { DialogElement } from "@aura/ui" + +type Controls = ReturnType + +const SORTS: { id: SubjectSort; label: string }[] = [ + { id: "recency", label: "Recency" }, + { id: "name", label: "Name" }, +] + +const RATED_STATES: { id: SubjectRatedState; label: string }[] = [ + { id: "all", label: "All" }, + { id: "unrated", label: "Unrated" }, + { id: "rated", label: "Rated" }, +] + +/** + * Search + filter + sort controls for the subjects list. Collapses the old + * three-modal design (SubjectListControls + FiltersModal + SortsModal) into a + * single inline search box and one `` holding the filter/sort toggles. + */ +export default function SubjectListControls(props: { + controls: Controls + count: number +}) { + let dialog: DialogElement | undefined + + const c = () => props.controls + const activeFilters = () => + c().levels().length + (c().ratedState() !== "all" ? 1 : 0) + + return ( +
+ ) => c().setSearch(e.detail)} + > + + + +
+ + + + + Filter & sort + 0}> + + {activeFilters()} + + + + + +
+
+ Sort by +
+ + {(s) => ( + c().setSort(s.id)} + > + {s.label} + + )} + +
+
+ +
+ Your evaluation +
+ + {(r) => ( + c().setRatedState(r.id)} + > + {r.label} + + )} + +
+
+ +
+ Connection level +
+ + {(level: ConnectionLevel) => ( + c().toggleLevel(level)} + > + {toTitleCase(level)} + + )} + +
+
+ +
+ c().reset()} + > + Clear + + dialog?.hide()} + > + Done + +
+
+
+ + + {props.count} result{props.count === 1 ? "" : "s"} + +
+
+ ) +} diff --git a/apps/core/src/components/list/list-state.tsx b/apps/core/src/components/list/list-state.tsx new file mode 100644 index 0000000..eef27bf --- /dev/null +++ b/apps/core/src/components/list/list-state.tsx @@ -0,0 +1,27 @@ +import { type JSX, Show } from "solid-js" + +/** Shared loading / empty / content scaffolding for list views. */ +export default function ListState(props: { + loading: boolean + empty: boolean + emptyText: string + children: JSX.Element +}) { + return ( + Loading…
} + > + + {props.emptyText} +
+ } + > + {props.children} + + + ) +} diff --git a/apps/core/src/components/notifications/notifications-checker.tsx b/apps/core/src/components/notifications/notifications-checker.tsx new file mode 100644 index 0000000..8450ebf --- /dev/null +++ b/apps/core/src/components/notifications/notifications-checker.tsx @@ -0,0 +1,51 @@ +import { createEffect, untrack } from "solid-js" +import { + createBrightIdProfileQuery, + createInboundConnectionsQuery, + createOutboundConnectionsQuery, +} from "@/queries/connections" +import { authStore } from "@/store/auth" +import { ingestNotifications, notificationsStore } from "@/store/notifications" +import { diffNotifications } from "@aura/domain/notifications" + +/** + * Headless checker: whenever the session's profile/connections queries refresh + * (their normal cadence — no extra polling stack), diff the fresh data against + * the tracked snapshot and ingest any new alerts. Renders nothing. + */ +export default function NotificationsChecker() { + const subjectId = () => authStore.user?.brightId ?? "" + + const profile = createBrightIdProfileQuery(subjectId) + const inbound = createInboundConnectionsQuery(subjectId) + const outbound = createOutboundConnectionsQuery(subjectId) + + createEffect(() => { + const id = subjectId() + const verifications = profile.data?.verifications + const inboundData = inbound.data + const outboundData = outbound.data + if (!id || !verifications || !inboundData || !outboundData) return + + // untrack: the diff reads (and ingest writes) the notifications store — + // tracking those reads would re-trigger this effect in a loop. It should + // only re-run when the queries deliver fresh data. + untrack(() => { + const now = Date.now() + ingestNotifications( + diffNotifications({ + subjectId: id, + verifications, + inbound: inboundData, + outbound: outboundData, + prevTracked: notificationsStore.tracked, + lastFetch: notificationsStore.lastFetch, + now, + }), + now, + ) + }) + }) + + return null +} diff --git a/apps/core/src/components/settings/logout-button.tsx b/apps/core/src/components/settings/logout-button.tsx new file mode 100644 index 0000000..24470e0 --- /dev/null +++ b/apps/core/src/components/settings/logout-button.tsx @@ -0,0 +1,24 @@ +import { useNavigate } from "@solidjs/router" +import { clearQueryCache } from "@/providers" +import { logout } from "@/store/auth" +import { resetRecovery } from "@/store/recovery" + +export default function LogoutButton() { + const navigate = useNavigate() + return ( + { + logout() + resetRecovery() + // wipe cached node/backup data so nothing of the session lingers + void clearQueryCache() + navigate("/login", { replace: true }) + }} + > + Logout + + ) +} diff --git a/apps/core/src/components/settings/setting-card.tsx b/apps/core/src/components/settings/setting-card.tsx new file mode 100644 index 0000000..5e6c49a --- /dev/null +++ b/apps/core/src/components/settings/setting-card.tsx @@ -0,0 +1,49 @@ +import { A } from "@solidjs/router" +import type { JSX } from "solid-js" +import { Show } from "solid-js" + +/** A settings row card — either a link (href) or a button (onClick). */ +export default function SettingCard(props: { + icon?: string + label?: string + href?: string + external?: boolean + onClick?: () => void + testid?: string + children?: JSX.Element +}) { + const card = ( + + + + + + {props.label} + + {props.children} + + ) + + return ( + + + {card} + + } + > + + {card} + + + + ) +} diff --git a/apps/core/src/components/settings/theme-toggle.tsx b/apps/core/src/components/settings/theme-toggle.tsx new file mode 100644 index 0000000..a22e3c2 --- /dev/null +++ b/apps/core/src/components/settings/theme-toggle.tsx @@ -0,0 +1,18 @@ +import SettingCard from "@/components/settings/setting-card" +import { preferencesStore, setTheme } from "@/store/preferences" + +export default function ThemeToggle() { + const isDark = () => preferencesStore.theme === "dark" + return ( + setTheme(isDark() ? "light" : "dark")} + > + + {preferencesStore.theme.toUpperCase()} + + + ) +} diff --git a/apps/core/src/components/settings/version-card.tsx b/apps/core/src/components/settings/version-card.tsx new file mode 100644 index 0000000..7b85b49 --- /dev/null +++ b/apps/core/src/components/settings/version-card.tsx @@ -0,0 +1,22 @@ +/** + * App version. Simplified from the source — no PWA/service-worker update flow + * yet (the old card checked `/versioning.txt` and prompted a SW update). + */ +export default function VersionCard() { + return ( + +
+
+ + Aura +
+

+ You are using version {__APP_VERSION__} +

+
+
+ ) +} diff --git a/apps/core/src/components/shared/level-score.tsx b/apps/core/src/components/shared/level-score.tsx new file mode 100644 index 0000000..b89987e --- /dev/null +++ b/apps/core/src/components/shared/level-score.tsx @@ -0,0 +1,32 @@ +import { Show } from "solid-js" +import { compactFormat } from "@/shared/lib/number" + +/** The "Level: X Score: Y" line used by profile and subject cards. */ +export default function LevelScore(props: { + level: number | null + score: number | null + testid?: string +}) { + return ( +
+ Level:{" "} + + {props.level ?? "-"} + + + Score:{" "} + + + {compactFormat(props.score!)} + + + +
+ ) +} diff --git a/apps/core/src/components/subject/connection-card.tsx b/apps/core/src/components/subject/connection-card.tsx new file mode 100644 index 0000000..50e5c18 --- /dev/null +++ b/apps/core/src/components/subject/connection-card.tsx @@ -0,0 +1,36 @@ +import Avatar from "@/components/home/avatar" +import { formatDuration } from "@/shared/lib/time" +import type { AuraNodeBrightIdConnection } from "@aura/domain/types/aura" + +/** Inbound-connection row: who, how close, and since when. */ +export default function ConnectionCard(props: { + connection: AuraNodeBrightIdConnection + name: string + onClick?: (id: string) => void +}) { + return ( + props.onClick?.(props.connection.id)} + > +
+ +
+

{props.name}

+

+ {formatDuration(props.connection.timestamp)} +

+
+
+ + {props.connection.level} + +
+ ) +} diff --git a/apps/core/src/components/subject/evidence-help.tsx b/apps/core/src/components/subject/evidence-help.tsx new file mode 100644 index 0000000..3cca800 --- /dev/null +++ b/apps/core/src/components/subject/evidence-help.tsx @@ -0,0 +1,42 @@ +/** "Evidence" section heading with its explainer dialog. */ +export default function EvidenceHelp() { + return ( +
+

Evidence

+ + +
+

+ Understanding the Evidence section +

+ + Evidence is what other participants think of this subject. + + Evaluations — + who rated this subject in the current role, with their confidence + and impact. + + + Connections /{" "} + Activity — their + BrightID connections (Player view), or the evaluations they have + made themselves (Trainer/Manager views). + + + Use it to decide your own evaluation — tap any row for a full + credibility breakdown. + + +
+
+
+ ) +} diff --git a/apps/core/src/components/subject/subject-profile-card.tsx b/apps/core/src/components/subject/subject-profile-card.tsx new file mode 100644 index 0000000..731fc39 --- /dev/null +++ b/apps/core/src/components/subject/subject-profile-card.tsx @@ -0,0 +1,61 @@ +import { Show } from "solid-js" +import Avatar from "@/components/home/avatar" +import LevelScore from "@/components/shared/level-score" +import { useSubjectName } from "@/hooks/use-backup" +import { useMyRating } from "@/hooks/use-my-evaluations" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { useViewMode } from "@/hooks/use-view-mode" +import { confidenceLabel } from "@aura/domain/labels" + +/** Subject header card: identity, standing, your evaluation, evaluate action. */ +export default function SubjectProfileCard(props: { + subjectId: () => string + onEvaluate: () => void +}) { + const name = useSubjectName(props.subjectId) + const vm = useViewMode() + const v = useSubjectVerifications(props.subjectId, vm.currentEvaluationCategory) + const my = useMyRating(props.subjectId, vm.currentEvaluationCategory) + + return ( + +
+
+ +
+

+ {name()} +

+ +
+
+ + + Edit + + +
+ +
+ Your evaluation:{" "} + -}> + 0 ? "text-aura-success" : "text-destructive"}`} + > + {my.rating()! > 0 ? "Positive" : "Negative"} —{" "} + {confidenceLabel(my.rating()!)} ({my.rating()! > 0 ? "+" : ""} + {my.rating()}) + + +
+
+ ) +} diff --git a/apps/core/src/globals.d.ts b/apps/core/src/globals.d.ts new file mode 100644 index 0000000..c1264fa --- /dev/null +++ b/apps/core/src/globals.d.ts @@ -0,0 +1,2 @@ +/** Injected by vite `define` from package.json version. */ +declare const __APP_VERSION__: string diff --git a/apps/core/src/hooks/use-backup.ts b/apps/core/src/hooks/use-backup.ts new file mode 100644 index 0000000..fd0251b --- /dev/null +++ b/apps/core/src/hooks/use-backup.ts @@ -0,0 +1,35 @@ +import { createMemo } from "solid-js" +import { createProfileDataQuery } from "@/queries/backup" +import { authStore } from "@/store/auth" + +/** The decrypted BrightID backup for the logged-in user. */ +export function useBackup() { + return createProfileDataQuery( + () => authStore.user?.brightId ?? "", + () => authStore.user?.password ?? "", + ) +} + +/** + * Resolve display names from the backup (self or a connection); falls back to + * a short id. Use this when a component renders many subjects — it shares one + * backup query across all lookups. + */ +export function useNameResolver() { + const backup = useBackup() + return (id: string): string => { + const data = backup.data + if (!data) return id.slice(0, 7) + const info = + id === data.userData.id + ? data.userData + : data.connections.find((c) => c.id === id) + return info?.name ?? id.slice(0, 7) + } +} + +/** Reactive display name for a single subject. */ +export function useSubjectName(subjectId: () => string) { + const nameOf = useNameResolver() + return createMemo(() => nameOf(subjectId())) +} diff --git a/apps/core/src/hooks/use-evaluate-subject.ts b/apps/core/src/hooks/use-evaluate-subject.ts new file mode 100644 index 0000000..ce9acd5 --- /dev/null +++ b/apps/core/src/hooks/use-evaluate-subject.ts @@ -0,0 +1,31 @@ +import { EvaluationValue } from "@aura/domain/types/evaluations" +import type { EvaluationCategory } from "@aura/domain/types/evaluations" +import { createEvaluateMutation } from "@/queries/evaluations" +import { useViewMode } from "@/hooks/use-view-mode" + +/** + * Submit an evaluation for a subject. + * + * `newRating` is a signed magnitude: a negative value is a NEGATIVE evaluation, + * positive is POSITIVE, and the confidence is its absolute value. Category + * defaults to the current view mode's category, with an optional override. + */ +export function useEvaluateSubject(category?: () => EvaluationCategory) { + const { currentEvaluationCategory } = useViewMode() + const mutation = createEvaluateMutation() + + const submitEvaluation = (subjectId: string, newRating: number) => + mutation.mutateAsync({ + evaluated: subjectId, + evaluation: + newRating < 0 ? EvaluationValue.NEGATIVE : EvaluationValue.POSITIVE, + confidence: Math.abs(newRating), + category: category?.() ?? currentEvaluationCategory(), + }) + + return { + submitEvaluation, + isPending: () => mutation.isPending, + error: () => mutation.error, + } +} diff --git a/apps/core/src/hooks/use-levelup-progress.ts b/apps/core/src/hooks/use-levelup-progress.ts new file mode 100644 index 0000000..1f91e7a --- /dev/null +++ b/apps/core/src/hooks/use-levelup-progress.ts @@ -0,0 +1,70 @@ +import { createMemo } from "solid-js" +import { useMyEvaluations } from "@/hooks/use-my-evaluations" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { authStore } from "@/store/auth" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +const PLAYER_EVALUATION_MINIMUM_COUNT_BEFORE_TRAINING = 3 + +export interface LevelupProgress { + isUnlocked: boolean + reason: string + percent: number +} + +export function useLevelupProgress(category: () => EvaluationCategory) { + const subjectId = () => authStore.user?.brightId ?? "" + const playerEval = useSubjectVerifications( + subjectId, + () => EvaluationCategory.PLAYER, + ) + const trainerEval = useSubjectVerifications( + subjectId, + () => EvaluationCategory.TRAINER, + ) + const { myRatings } = useMyEvaluations() + + const ratingsToBeDone = createMemo(() => { + const r = myRatings() + if (!r) return undefined + return Math.max( + PLAYER_EVALUATION_MINIMUM_COUNT_BEFORE_TRAINING - + r.filter((x) => Number(x.rating)).length, + 0, + ) + }) + + return createMemo(() => { + switch (category()) { + case EvaluationCategory.PLAYER: { + const todo = ratingsToBeDone() ?? 0 + return { + isUnlocked: todo <= 0, + reason: `${todo} more evaluation${todo > 1 ? "s" : ""} to unlock Level Up`, + percent: + ((myRatings()?.length ?? 0) / + PLAYER_EVALUATION_MINIMUM_COUNT_BEFORE_TRAINING) * + 100, + } + } + case EvaluationCategory.TRAINER: { + const lvl = playerEval.auraLevel() + return { + isUnlocked: !!lvl && lvl >= 2, + reason: "Reach Player level 2 to unlock", + percent: lvl ?? 0, + } + } + case EvaluationCategory.MANAGER: { + const lvl = trainerEval.auraLevel() + return { + isUnlocked: !!lvl && lvl >= 1, + reason: "Reach Trainer level 1 to unlock", + percent: lvl ?? 0, + } + } + default: + return { isUnlocked: false, reason: "", percent: 0 } + } + }) +} diff --git a/apps/core/src/hooks/use-my-evaluations.ts b/apps/core/src/hooks/use-my-evaluations.ts new file mode 100644 index 0000000..daabe40 --- /dev/null +++ b/apps/core/src/hooks/use-my-evaluations.ts @@ -0,0 +1,104 @@ +import { createMemo } from "solid-js" +import { createOutboundConnectionsQuery } from "@/queries/connections" +import { authStore } from "@/store/auth" +import { operationsStore } from "@/store/operations" +import type { AuraRating } from "@aura/domain/types/aura" +import type { EvaluateOperation } from "@aura/domain/types/evaluations" +import { + EvaluationCategory, + EvaluationValue, +} from "@aura/domain/types/evaluations" + +const PENDING_STATES: ReadonlySet = new Set([ + "INIT", + "SENT", + "UNKNOWN", +]) + +export function useMyEvaluations() { + const subjectId = () => authStore.user?.brightId ?? "" + const query = createOutboundConnectionsQuery(subjectId) + + const myRatings = createMemo(() => { + const data = query.data + const id = subjectId() + if (!data || !id) return null + + const serverRatings = data.flatMap((c) => + (c.auraEvaluations ?? []).map((e) => ({ + fromBrightId: id, + toBrightId: c.id, + rating: String( + (e.evaluation === EvaluationValue.POSITIVE ? 1 : -1) * e.confidence, + ), + category: e.category, + id: 0, + createdAt: new Date(e.modified || c.timestamp).toISOString(), + updatedAt: new Date(e.modified || c.timestamp).toISOString(), + timestamp: e.modified || c.timestamp, + isPending: false, + verifications: c.verifications, + })), + ) + + const pending = Object.values(operationsStore.byHash).filter((op) => + PENDING_STATES.has(op.state), + ) + const pendingRatings = pending.map((op) => ({ + fromBrightId: id, + toBrightId: op.evaluated, + rating: String( + (op.evaluation === EvaluationValue.POSITIVE ? 1 : -1) * op.confidence, + ), + category: op.category, + timestamp: op.timestamp, + createdAt: new Date(op.timestamp).toISOString(), + updatedAt: new Date(op.timestamp).toISOString(), + isPending: true, + })) + + const pendingKeys = new Set( + pendingRatings.map((r) => `${r.toBrightId}:${r.category}`), + ) + return serverRatings + .filter((r) => !pendingKeys.has(`${r.toBrightId}:${r.category}`)) + .concat(pendingRatings) + .sort((a, b) => a.timestamp - b.timestamp) + }) + + return { + query, + loading: () => query.isLoading, + connections: () => query.data, + myRatings, + } +} + +/** Ratings filtered to a single evaluation category. */ +export function useMyEvaluationData(category: () => EvaluationCategory) { + const ctx = useMyEvaluations() + const myRatings = createMemo( + () => ctx.myRatings()?.filter((r) => r.category === category()) ?? null, + ) + return { ...ctx, myRatings } +} + +/** + * My rating of one subject in one category (pending ops included). + * `rating()` is the signed value, undefined when not rated. + */ +export function useMyRating( + subjectId: () => string, + category: () => EvaluationCategory, +) { + const { myRatings } = useMyEvaluations() + const found = createMemo(() => + myRatings()?.find( + (r) => r.toBrightId === subjectId() && r.category === category(), + ), + ) + return { + rating: () => (found() ? Number(found()!.rating) : undefined), + isPending: () => found()?.isPending ?? false, + } +} diff --git a/apps/core/src/hooks/use-require-session.ts b/apps/core/src/hooks/use-require-session.ts new file mode 100644 index 0000000..44c45ba --- /dev/null +++ b/apps/core/src/hooks/use-require-session.ts @@ -0,0 +1,14 @@ +import { useNavigate } from "@solidjs/router" +import { createEffect } from "solid-js" +import { authStore } from "@/store/auth" + +export function useRequireSession() { + const navigate = useNavigate() + const subjectId = () => authStore.user?.brightId + + createEffect(() => { + if (!subjectId()) navigate("/login", { replace: true }) + }) + + return subjectId +} diff --git a/apps/core/src/hooks/use-subject-inbound-evaluations.ts b/apps/core/src/hooks/use-subject-inbound-evaluations.ts new file mode 100644 index 0000000..b040a28 --- /dev/null +++ b/apps/core/src/hooks/use-subject-inbound-evaluations.ts @@ -0,0 +1,170 @@ +import { createMemo } from "solid-js" +import { useNameResolver } from "@/hooks/use-backup" +import { + createBrightIdProfileQuery, + createInboundConnectionsQuery, + createOutboundConnectionsQuery, +} from "@/queries/connections" +import { categoryEvaluatedBy } from "@aura/domain/labels" +import { impactShare } from "@aura/domain/score" +import type { + AuraEvaluation, + AuraImpactRaw, + AuraNodeBrightIdConnection, + ConnectionLevel, +} from "@aura/domain/types/aura" +import { + type EvaluationCategory, + EvaluationValue, +} from "@aura/domain/types/evaluations" +import { getAuraVerification } from "@aura/domain/verifications" + +/** One evaluation row: the other party, their standing, and the rating. */ +export interface InboundEvaluation { + evaluatorId: string + name: string + /** Signed rating: +confidence for positive, -confidence for negative. */ + rating: number + confidence: number + timestamp: number + /** The other party's level/score in the relevant role. */ + level: number | null + score: number | null + /** This evaluation's share of the subject's total impact (percent). */ + impactPercent: number | null +} + +/** Flatten one connection's evaluations (in a category) into rows. */ +function toRows( + connection: AuraNodeBrightIdConnection, + category: EvaluationCategory, + options: { + nameOf: (id: string) => string + /** Which role's level/score to show for the other party. */ + standingCategory: EvaluationCategory + impacts?: AuraImpactRaw[] + }, +): InboundEvaluation[] { + const standing = getAuraVerification( + connection.verifications, + options.standingCategory, + ) + return (connection.auraEvaluations ?? []) + .filter((e: AuraEvaluation) => e.category === category) + .map((e) => ({ + evaluatorId: connection.id, + name: options.nameOf(connection.id), + rating: + (e.evaluation === EvaluationValue.POSITIVE ? 1 : -1) * e.confidence, + confidence: e.confidence, + timestamp: e.modified || connection.timestamp, + level: standing?.level ?? null, + score: standing?.score ?? null, + impactPercent: impactShare(options.impacts, connection.id), + })) +} + +const byRecency = (a: InboundEvaluation, b: InboundEvaluation) => + b.timestamp - a.timestamp + +/** + * Inbound evaluations of a subject in a category, derived from the subject's + * inbound connections (`auraEvaluations` ride along on each connection). + * Ported from the old `useSubjectInboundEvaluations`, minus its filter/sort + * machinery — lists are short enough to sort by recency here. + */ +export function useSubjectInboundEvaluations( + subjectId: () => string, + category: () => EvaluationCategory, +) { + const query = createInboundConnectionsQuery(subjectId) + // The subject's own profile carries the per-evaluator impacts (cached by id). + const profile = createBrightIdProfileQuery(subjectId) + const nameOf = useNameResolver() + + const evaluations = createMemo(() => { + const data = query.data + if (!data) return null + const impacts = + getAuraVerification(profile.data?.verifications, category())?.impacts ?? + undefined + return data + .flatMap((c) => + toRows(c, category(), { + nameOf, + standingCategory: categoryEvaluatedBy[category()], + impacts, + }), + ) + .sort(byRecency) + }) + + const positiveCount = createMemo( + () => evaluations()?.filter((e) => e.rating > 0).length, + ) + const negativeCount = createMemo( + () => evaluations()?.filter((e) => e.rating < 0).length, + ) + + return { + loading: () => query.isLoading, + evaluations, + positiveCount, + negativeCount, + } +} + +/** + * The subject's outgoing evaluations in a category — their "activity" (e.g. + * a player's evaluations of subjects). Same row shape; `evaluatorId` holds + * the *evaluated* subject's id here. + */ +export function useSubjectOutboundEvaluations( + subjectId: () => string, + category: () => EvaluationCategory, +) { + const query = createOutboundConnectionsQuery(subjectId) + const nameOf = useNameResolver() + + const evaluations = createMemo(() => { + const data = query.data + if (!data) return null + return data + .flatMap((c) => + // The other party is the evaluated subject — show their standing in + // the evaluated category itself. + toRows(c, category(), { nameOf, standingCategory: category() }), + ) + .sort(byRecency) + }) + + return { loading: () => query.isLoading, evaluations } +} + +// Mirrors the old app's connection ordering: closer levels first, then recency. +const LEVEL_PRIORITY: Record = { + "already known": 1, + recovery: 2, + "just met": 3, + "aura only": 4, + suspicious: 5, + reported: 6, +} + +/** A subject's inbound connections, closest level first, then most recent. */ +export function useSubjectInboundConnections(subjectId: () => string) { + const query = createInboundConnectionsQuery(subjectId) + const nameOf = useNameResolver() + + const connections = createMemo(() => { + const data = query.data + if (!data) return null + return [...data].sort((a, b) => { + const pa = LEVEL_PRIORITY[a.level] ?? Number.POSITIVE_INFINITY + const pb = LEVEL_PRIORITY[b.level] ?? Number.POSITIVE_INFINITY + return pa === pb ? b.timestamp - a.timestamp : pa - pb + }) + }) + + return { loading: () => query.isLoading, connections, nameOf } +} diff --git a/apps/core/src/hooks/use-subject-verifications.ts b/apps/core/src/hooks/use-subject-verifications.ts new file mode 100644 index 0000000..80e82db --- /dev/null +++ b/apps/core/src/hooks/use-subject-verifications.ts @@ -0,0 +1,26 @@ +import { createMemo } from "solid-js" +import { createBrightIdProfileQuery } from "@/queries/connections" +import { parseVerifications } from "@aura/domain/verifications" +import type { EvaluationCategory } from "@aura/domain/types/evaluations" + +/** Level / score / impacts for a subject in a category, from the aura node. */ +export function useSubjectVerifications( + subjectId: () => string, + category: () => EvaluationCategory, +) { + const query = createBrightIdProfileQuery(subjectId) + const parsed = createMemo(() => + parseVerifications(query.data?.verifications, category()), + ) + + return { + query, + loading: () => query.isLoading, + isFetching: () => query.isFetching, + auraLevel: () => parsed().auraLevel, + auraScore: () => parsed().auraScore, + auraImpacts: () => parsed().auraImpacts, + userHasRecovery: () => parsed().userHasRecovery, + refresh: () => query.refetch(), + } +} diff --git a/apps/core/src/hooks/use-subjects-list.ts b/apps/core/src/hooks/use-subjects-list.ts new file mode 100644 index 0000000..f9cbfc5 --- /dev/null +++ b/apps/core/src/hooks/use-subjects-list.ts @@ -0,0 +1,120 @@ +import { createMemo, createSignal } from "solid-js" +import { useBackup } from "@/hooks/use-backup" +import { useMyEvaluationData } from "@/hooks/use-my-evaluations" +import { EvaluationCategory } from "@aura/domain/types/evaluations" +import type { + BrightIdBackupConnection, + ConnectionLevel, +} from "@aura/domain/types/aura" + +export type SubjectSort = "recency" | "name" +export type SubjectRatedState = "all" | "rated" | "unrated" + +const LEVELS: ConnectionLevel[] = [ + "reported", + "suspicious", + "just met", + "already known", + "recovery", + "aura only", +] + +/** + * Subjects to evaluate: the user's backup connections with a default ordering + * (newest first, un-rated "already known" / "recovery" promoted to the top), + * plus local search / filter / sort controls. + * + * Filter/sort state is local to the route via signals — it doesn't need to + * outlive the page, so no store. When a non-default sort is active the + * promotion is dropped (the explicit sort wins). + */ +export function useSubjectsList() { + const backup = useBackup() + const { myRatings } = useMyEvaluationData(() => EvaluationCategory.SUBJECT) + + const [search, setSearch] = createSignal("") + const [sort, setSort] = createSignal("recency") + const [levels, setLevels] = createSignal([]) + const [ratedState, setRatedState] = createSignal("all") + + function toggleLevel(level: ConnectionLevel) { + setLevels((prev) => + prev.includes(level) + ? prev.filter((l) => l !== level) + : [...prev, level], + ) + } + + function reset() { + setSearch("") + setSort("recency") + setLevels([]) + setRatedState("all") + } + + const subjects = createMemo(() => { + const conns = backup.data?.connections + const ratings = myRatings() + if (!conns || ratings === null) return null + + const isRated = (id: string) => ratings.some((r) => r.toBrightId === id) + + const unique = [...new Map(conns.map((c) => [c.id, c])).values()] + + const query = search().trim().toLowerCase() + const activeLevels = levels() + const rated = ratedState() + + const filtered = unique.filter((c) => { + if (query) { + const name = (c.name ?? "").toLowerCase() + if (!name.includes(query) && !c.id.toLowerCase().includes(query)) + return false + } + if (activeLevels.length && !activeLevels.includes(c.level)) return false + if (rated === "rated" && !isRated(c.id)) return false + if (rated === "unrated" && isRated(c.id)) return false + return true + }) + + if (sort() === "name") { + return filtered.sort((a, b) => + (a.name ?? a.id).localeCompare(b.name ?? b.id), + ) + } + + // Default recency sort: newest first, with un-rated "already known" / + // "recovery" connections promoted to the top. + const sorted = filtered.sort( + (a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0), + ) + const [promoted, rest] = sorted.reduce< + [BrightIdBackupConnection[], BrightIdBackupConnection[]] + >( + (acc, c) => { + const promote = + !isRated(c.id) && + (c.level === "already known" || c.level === "recovery") + acc[promote ? 0 : 1].push(c) + return acc + }, + [[], []], + ) + return [...promoted, ...rest] + }) + + return { + subjects, + loading: () => backup.isLoading || myRatings() === null, + levelOptions: LEVELS, + search, + setSearch, + sort, + setSort, + levels, + toggleLevel, + ratedState, + setRatedState, + reset, + } +} diff --git a/apps/core/src/hooks/use-view-mode.ts b/apps/core/src/hooks/use-view-mode.ts new file mode 100644 index 0000000..02e9694 --- /dev/null +++ b/apps/core/src/hooks/use-view-mode.ts @@ -0,0 +1,57 @@ +import { useParams, useSearchParams } from "@solidjs/router" +import { createMemo } from "solid-js" +import { + viewAsToViewMode, + viewModeSubjectString, + viewModeToEvaluatorViewMode, + viewModeToViewAs, + viewSlugToViewMode, +} from "@aura/domain/view-mode" +import { PreferredView } from "@aura/domain/types/dashboard" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +/** + * Reactive view-mode state. Resolution order: + * 1. `/home/:view` route slug (player|trainer|manager) + * 2. `?viewas=` search param + * 3. default (Player) + */ +export function useViewMode() { + const routeParams = useParams() + const [params, setParams] = useSearchParams() + + const currentViewMode = createMemo(() => { + const slug = routeParams.view + if (slug && slug in viewSlugToViewMode) return viewSlugToViewMode[slug] + + const viewAs = params.viewas + if ( + typeof viewAs === "string" && + (Object.values(EvaluationCategory) as string[]).includes(viewAs) + ) { + return viewAsToViewMode[viewAs as EvaluationCategory] + } + return PreferredView.PLAYER + }) + + const currentEvaluationCategory = createMemo( + () => viewModeToViewAs[currentViewMode()], + ) + const currentRoleEvaluatorEvaluationCategory = createMemo( + () => viewModeToViewAs[viewModeToEvaluatorViewMode[currentViewMode()]], + ) + const subjectViewModeTitle = createMemo( + () => viewModeSubjectString[currentViewMode()], + ) + + const updateViewAs = (value: EvaluationCategory) => + setParams({ viewas: value }) + + return { + currentViewMode, + currentEvaluationCategory, + currentRoleEvaluatorEvaluationCategory, + subjectViewModeTitle, + updateViewAs, + } +} diff --git a/apps/core/src/providers.tsx b/apps/core/src/providers.tsx index 84e3dd0..e7c046f 100644 --- a/apps/core/src/providers.tsx +++ b/apps/core/src/providers.tsx @@ -1,14 +1,92 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" -import type { ParentComponent } from "solid-js" +import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister" +import { QueryClient, type Query } from "@tanstack/solid-query" +import { PersistQueryClientProvider } from "@tanstack/solid-query-persist-client" +import localforage from "localforage" +import { createEffect, type ParentComponent } from "solid-js" +import OpNotifications from "@/components/evaluation/op-notifications" +import NotificationsChecker from "@/components/notifications/notifications-checker" +import { preferencesStore } from "@/store/preferences" +import { isNotFound } from "@aura/domain/http" -const queryClient = new QueryClient() +const PERSIST_MAX_AGE = 1000 * 60 * 60 * 24 // 24h + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // keep cache around long enough to be restored from disk after a refresh + gcTime: PERSIST_MAX_AGE, + // 404 = "no profile on the node yet" — a real answer, don't retry it + retry: (failureCount, error) => !isNotFound(error) && failureCount < 3, + }, + }, +}) + +// Dedicated IndexedDB store so the query cache doesn't clash with other state. +const cacheStore = localforage.createInstance({ + name: "aura", + storeName: "query-cache", +}) + +const PERSIST_KEY = "aura-query-cache" + +const persister = createAsyncStoragePersister({ + storage: { + getItem: (key) => cacheStore.getItem(key), + setItem: (key, value) => cacheStore.setItem(key, value), + removeItem: (key) => cacheStore.removeItem(key), + }, + key: PERSIST_KEY, +}) + +/** Wipe the query cache (memory + persisted) — call on logout. */ +export async function clearQueryCache(): Promise { + queryClient.clear() + await cacheStore.removeItem(PERSIST_KEY) +} + +// Allowlist: only these query roots are safe to write to disk. Everything else +// (decrypted backups, photos, anything password-keyed) stays in memory only. +// Opt-in by design — a new query is non-persisted until explicitly added here. +const PERSISTABLE_KEYS = new Set([ + "connections", + "brightid-profile", +]) + +function shouldDehydrateQuery(query: Query): boolean { + const root = query.queryKey?.[0] + if (typeof root !== "string" || !PERSISTABLE_KEYS.has(root)) return false + return query.state.status === "success" +} const Providers: ParentComponent = (props) => { + // Apply the persisted theme to the document root (tailwind `.dark` variant). + createEffect(() => { + document.documentElement.classList.toggle( + "dark", + preferencesStore.theme === "dark", + ) + }) + return ( - + queryClient.invalidateQueries()} + > + + {props.children} - + ) } diff --git a/apps/core/src/queries/backup.ts b/apps/core/src/queries/backup.ts new file mode 100644 index 0000000..6b09b6c --- /dev/null +++ b/apps/core/src/queries/backup.ts @@ -0,0 +1,64 @@ +import { createQuery, queryOptions } from "@tanstack/solid-query" +import { getText, RECOVERY_API_BASE } from "@/shared/lib/api" +import { decryptData, hash } from "@aura/domain/crypto" +import type { BrightIdBackup } from "@aura/domain/types/aura" + +/** Raw (still-encrypted) backup blob — keyed by the auth key `hash(id+pw)`. */ +export const encryptedUserDataQueryOptions = (key: string) => + queryOptions({ + queryKey: ["encrypted-user-data", key], + queryFn: ({ signal }) => + getText(`${RECOVERY_API_BASE}/backups/${key}/data`, signal), + staleTime: 5_000_000, + enabled: !!key, + }) + +/** Decrypted BrightID backup (user data + connections). */ +export const profileDataQueryOptions = (brightId: string, password: string) => { + // Auth key derived from id+password — used as the cache key so the raw + // password never lands in the query key (and thus never in devtools/storage). + const key = brightId && password ? hash(brightId + password) : "" + return queryOptions({ + queryKey: ["profile-data", key], + queryFn: async ({ signal }) => { + const text = await getText(`${RECOVERY_API_BASE}/backups/${key}/data`, signal) + return JSON.parse(decryptData(text, password)) as BrightIdBackup + }, + retry: 0, + staleTime: 5_000_000, + enabled: !!brightId && !!password, + }) +} + +/** Decrypted profile photo (base64), stored per-connection in the backup. */ +export const profilePhotoQueryOptions = ( + key: string, + brightId: string, + password: string, +) => + queryOptions({ + queryKey: ["profile-photo", key, brightId], + queryFn: async ({ signal }) => { + const text = await getText( + `${RECOVERY_API_BASE}/backups/${key}/${brightId}`, + signal, + ) + return decryptData(text, password) + }, + retry: 0, + staleTime: 5_000_000, + enabled: !!key && !!brightId && !!password, + }) + +// ─── Solid query hooks ────────────────────────────────────────────────────── + +export const createProfileDataQuery = ( + brightId: () => string, + password: () => string, +) => createQuery(() => profileDataQueryOptions(brightId(), password())) + +export const createProfilePhotoQuery = ( + key: () => string, + brightId: () => string, + password: () => string, +) => createQuery(() => profilePhotoQueryOptions(key(), brightId(), password())) diff --git a/apps/core/src/queries/connections.ts b/apps/core/src/queries/connections.ts new file mode 100644 index 0000000..8de8b99 --- /dev/null +++ b/apps/core/src/queries/connections.ts @@ -0,0 +1,53 @@ +import { createQuery, queryOptions } from "@tanstack/solid-query" +import { getJson, NODE_API_BASE } from "@/shared/lib/api" +import type { + AuraNodeBrightIdConnection, + AuraNodeConnectionsResponse, + BrightIdProfile, +} from "@aura/domain/types/aura" + +type Direction = "inbound" | "outbound" + +const connectionsQueryOptions = (id: string, direction: Direction) => + queryOptions({ + queryKey: ["connections", direction, id], + queryFn: ({ signal }) => + getJson( + `${NODE_API_BASE}/brightid/v6/users/${id}/connections/${direction}?withVerifications=true`, + signal, + ).then((r) => r.data.connections), + staleTime: 30_000, + enabled: !!id, + }) + +export const inboundConnectionsQueryOptions = (id: string) => + connectionsQueryOptions(id, "inbound") + +export const outboundConnectionsQueryOptions = (id: string) => + connectionsQueryOptions(id, "outbound") + +export const brightIdProfileQueryOptions = (id: string) => + queryOptions({ + queryKey: ["brightid-profile", id], + queryFn: ({ signal }) => + getJson<{ data: BrightIdProfile }>( + `${NODE_API_BASE}/brightid/v6/users/${id}/profile`, + signal, + ).then((r) => r.data), + staleTime: 60_000, + enabled: !!id, + }) + +// ─── Solid query hooks ────────────────────────────────────────────────────── +// Accept reactive accessors so the query re-keys when the id changes. + +export const createInboundConnectionsQuery = (id: () => string) => + createQuery(() => inboundConnectionsQueryOptions(id())) + +export const createOutboundConnectionsQuery = (id: () => string) => + createQuery(() => outboundConnectionsQueryOptions(id())) + +export const createBrightIdProfileQuery = (id: () => string) => + createQuery(() => brightIdProfileQueryOptions(id())) + +export type { AuraNodeBrightIdConnection } diff --git a/apps/core/src/queries/contacts.ts b/apps/core/src/queries/contacts.ts new file mode 100644 index 0000000..69d5aa4 --- /dev/null +++ b/apps/core/src/queries/contacts.ts @@ -0,0 +1,31 @@ +import { createMutation } from "@tanstack/solid-query" +import bcrypt from "bcryptjs" +import { + BASE_SALT_FOR_CONTACTS, + normalizeContactValue, +} from "@/shared/lib/contacts" +import { postJson } from "@/shared/lib/api" + +const GET_VERIFIED_API = "https://aura-get-verified.vercel.app" + +/** + * Hash a contact (fixed salt, so the server can match it later) and register + * the hash with the get-verified service. Returns the hash for local storage. + */ +export const createStoreContactMutation = () => + createMutation(() => ({ + mutationFn: async (value: string) => { + const hash = await bcrypt.hash( + normalizeContactValue(value), + BASE_SALT_FOR_CONTACTS, + ) + // A SyntaxError after a 2xx means an empty/non-JSON body — that's fine, + // only the status matters here (non-2xx already threw a plain Error). + await postJson(`${GET_VERIFIED_API}/api/create-social`, { hash }).catch( + (e) => { + if (!(e instanceof SyntaxError)) throw e + }, + ) + return hash + }, + })) diff --git a/apps/core/src/queries/evaluations.ts b/apps/core/src/queries/evaluations.ts new file mode 100644 index 0000000..080f865 --- /dev/null +++ b/apps/core/src/queries/evaluations.ts @@ -0,0 +1,55 @@ +import { createMutation, useQueryClient } from "@tanstack/solid-query" +import { b64ToUint8Array } from "@aura/domain/crypto" +import { submitEvaluateOperation } from "@aura/domain/operations" +import type { + EvaluationCategory, + EvaluationValue, +} from "@aura/domain/types/evaluations" +import { NODE_API_BASE } from "@/shared/lib/api" +import { authStore } from "@/store/auth" +import { upsertOperation } from "@/store/operations" + +export interface EvaluateMutationVars { + evaluated: string + evaluation: EvaluationValue + confidence: number + category: EvaluationCategory +} + +/** + * Mutation that builds, signs, and submits an Evaluate operation, tracking it + * optimistically in the operations store and invalidating the reads that show + * the new rating once it settles. + */ +export function createEvaluateMutation() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: async (vars: EvaluateMutationVars) => { + const user = authStore.user + if (!user) throw new Error("Not authenticated") + + return submitEvaluateOperation(NODE_API_BASE, { + evaluator: user.brightId, + evaluated: vars.evaluated, + evaluation: vars.evaluation, + confidence: vars.confidence, + category: vars.category, + timestamp: Date.now(), + secretKey: b64ToUint8Array(authStore.secretKey), + }) + }, + // Track the pending op as soon as the node accepts it (state "INIT"). + onSuccess: (op) => upsertOperation(op), + onSettled: (_data, _err, vars) => { + // The evaluator's outbound connections feed `use-my-evaluations`, and the + // subject's brightid-profile carries its rating. + queryClient.invalidateQueries({ + queryKey: ["connections", "outbound", authStore.user?.brightId], + }) + queryClient.invalidateQueries({ + queryKey: ["brightid-profile", vars.evaluated], + }) + }, + })) +} diff --git a/apps/core/src/routes/[...404].tsx b/apps/core/src/routes/[...404].tsx new file mode 100644 index 0000000..b354b3d --- /dev/null +++ b/apps/core/src/routes/[...404].tsx @@ -0,0 +1,23 @@ +import { A } from "@solidjs/router" +import FadeIn from "@/components/motions/fade-in" + +// `[...404].tsx` => `*` catch-all. +export default function NotFound() { + return ( +
+ + 404 + + + + This page doesn't exist. + + + + + Go home + + +
+ ) +} diff --git a/apps/core/src/routes/about.tsx b/apps/core/src/routes/about.tsx index 4e012a0..0c36c9b 100644 --- a/apps/core/src/routes/about.tsx +++ b/apps/core/src/routes/about.tsx @@ -1,13 +1,32 @@ -import { counter } from "@/store/onboarding" +import FadeIn from "@/components/motions/fade-in" // `about.tsx` => `/about`. function About() { - // Same module-scoped store, different route — state is shared automatically. return ( -
-

About

-

This page reads the same store. Count is {counter.state.count}.

-
+
+
+ + About + + + + Aura is a social consensus protocol where players evaluate each + other to verify unique humans, built on BrightID. + + +
+ +
+ + Version + {__APP_VERSION__} + + + Powered by: + BrightID + +
+
) } diff --git a/apps/core/src/routes/contact-info.tsx b/apps/core/src/routes/contact-info.tsx new file mode 100644 index 0000000..36a6de4 --- /dev/null +++ b/apps/core/src/routes/contact-info.tsx @@ -0,0 +1,144 @@ +import { createSignal, For, Show } from "solid-js" +import { toast } from "@aura/ui" +import type { DialogElement } from "@aura/ui" +import { useRequireSession } from "@/hooks/use-require-session" +import { createStoreContactMutation } from "@/queries/contacts" +import { + addContact, + contactsStore, + type ContactType, +} from "@/store/contacts" + +const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) +const isValidPhone = (phone: string) => /^\+?[1-9]\d{1,14}$/.test(phone) + +/** /contact-info — hashed contact registry so friends can find you. */ +export default function ContactInfoPage() { + useRequireSession() + let dialog: DialogElement | undefined + + const [type, setType] = createSignal("email") + const [value, setValue] = createSignal("") + const [error, setError] = createSignal("") + + const store = createStoreContactMutation() + + const submit = () => { + const v = value().trim() + if (type() === "email" && !isValidEmail(v)) + return setError("Invalid email address") + if (type() === "phone" && !isValidPhone(v)) + return setError("Invalid phone number") + + store.mutate(v, { + onSuccess: (hash) => { + addContact({ type: type(), hash }) + setValue("") + setError("") + dialog?.hide() + toast.success("Contact saved", { + description: "Only a hash of it is stored.", + }) + }, + onError: (e: unknown) => + setError(e instanceof Error ? e.message : String(e)), + }) + } + + return ( +
+ Your Contact info + + + This is how your friends and family find you and ask for verification. + Contacts are stored as hashes, so the values themselves stay private. + + +
+ 0} + fallback={ +
+ No contact info added yet. +
+ } + > + + {(contact) => ( + + {contact.type} + ****************** + + )} + +
+
+ + + + + Add contact info + + +
+ Add contact information + +
+ { + setType("email") + setError("") + }} + > + Email + + { + setType("phone") + setError("") + }} + > + Phone + +
+ +
+ ) => { + setValue(e.detail) + setError("") + }} + /> + +

{error()}

+
+
+ + + + Saving… + + +
+
+
+ ) +} diff --git a/apps/core/src/routes/dashboard.tsx b/apps/core/src/routes/dashboard.tsx new file mode 100644 index 0000000..8e149bd --- /dev/null +++ b/apps/core/src/routes/dashboard.tsx @@ -0,0 +1,106 @@ +import { A, useNavigate } from "@solidjs/router" +import { For, Show } from "solid-js" +import type { DialogElement } from "@aura/ui" +import { useLevelupProgress } from "@/hooks/use-levelup-progress" +import { useRequireSession } from "@/hooks/use-require-session" +import { useViewMode } from "@/hooks/use-view-mode" +import { + viewModeToSlug, + viewModeToString, +} from "@aura/domain/view-mode" +import { PreferredView } from "@aura/domain/types/dashboard" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +const VIEWS: { view: PreferredView; gate: EvaluationCategory | null }[] = [ + { view: PreferredView.PLAYER, gate: null }, + { view: PreferredView.TRAINER, gate: EvaluationCategory.TRAINER }, + { + view: PreferredView.MANAGER_EVALUATING_TRAINER, + gate: EvaluationCategory.MANAGER, + }, +] + +const LINKS: { icon: string; label: string; href: string }[] = [ + { icon: "globe", label: "Domain Overview", href: "/domain-overview" }, + { icon: "users", label: "Evaluate", href: "/home" }, + { icon: "bell", label: "Notifications", href: "/notifications" }, + { icon: "shield", label: "Role Management", href: "/role-management" }, + { icon: "settings", label: "Settings", href: "/settings" }, +] + +/** /dashboard — preferred-view selector + quick-link grid. */ +export default function DashboardPage() { + useRequireSession() + const navigate = useNavigate() + const vm = useViewMode() + let dialog: DialogElement | undefined + + const trainerProgress = useLevelupProgress(() => EvaluationCategory.TRAINER) + const managerProgress = useLevelupProgress(() => EvaluationCategory.MANAGER) + const isUnlocked = (gate: EvaluationCategory | null) => { + if (gate === EvaluationCategory.TRAINER) return trainerProgress().isUnlocked + if (gate === EvaluationCategory.MANAGER) return managerProgress().isUnlocked + return true + } + + const selectView = (view: PreferredView) => { + dialog?.hide() + // The view lives in the route — /home/:view is the source of truth. + navigate(`/home/${viewModeToSlug[view]}`) + } + + return ( +
+ Dashboard + + +
+

Preferred view

+

+ {viewModeToString[vm.currentViewMode()]} +

+
+ + + + Change + +
+ Select your view + + {({ view, gate }) => ( + selectView(view)} + > + {viewModeToString[view]} + (locked) + + )} + +
+
+
+ +
+ + {(link) => ( + + + +

{link.label}

+
+
+ )} +
+
+
+ ) +} diff --git a/apps/core/src/routes/domain-overview.tsx b/apps/core/src/routes/domain-overview.tsx new file mode 100644 index 0000000..741ea49 --- /dev/null +++ b/apps/core/src/routes/domain-overview.tsx @@ -0,0 +1,64 @@ +import { For } from "solid-js" +import { useRequireSession } from "@/hooks/use-require-session" + +// Straight port of the old page's hardcoded stats — there is no domain-stats +// endpoint yet (same as the old app; wire real counts when one exists). +const STATS: { label: string; value: string; icon: string }[] = [ + { label: "Subjects", value: "1203", icon: "users" }, + { label: "Players", value: "247", icon: "user-check" }, + { label: "Trainers", value: "11", icon: "graduation-cap" }, + { label: "Managers", value: "3", icon: "shield" }, +] + +/** /domain-overview — static domain info card + member counts. */ +export default function DomainOverviewPage() { + useRequireSession() + + return ( +
+ Domain Overview + + +
+
+

Domain

+

BrightID

+
+
+

Creator

+

Sina.eth

+
+
+

Created at

+

23 May 03

+
+
+
+

About

+

+ BrightID is a digital identity solution that aims to revolutionize + how identities are verified online +

+
+
+ +
+ + {(stat) => ( + +
+ +

{stat.value}

+
+

{stat.label}

+
+ )} +
+
+
+ ) +} diff --git a/apps/core/src/routes/home/[view]/_layout.tsx b/apps/core/src/routes/home/[view]/_layout.tsx new file mode 100644 index 0000000..c85b3b4 --- /dev/null +++ b/apps/core/src/routes/home/[view]/_layout.tsx @@ -0,0 +1,84 @@ +import { A, useNavigate, useParams } from "@solidjs/router" +import { createEffect, type ParentProps, Show } from "solid-js" +import HomeHeader from "@/components/home/home-header" +import ProfileHeaderCard from "@/components/home/profile-header-card" +import ProfileInfoPerformance from "@/components/home/profile-info-performance" +import ProfileNotFoundHint from "@/components/home/profile-not-found-hint" +import { useLevelupProgress } from "@/hooks/use-levelup-progress" +import { useRequireSession } from "@/hooks/use-require-session" +import { useViewMode } from "@/hooks/use-view-mode" +import { + createBrightIdProfileQuery, + createInboundConnectionsQuery, +} from "@/queries/connections" +import { isNotFound } from "@aura/domain/http" +import { viewSlugToViewMode } from "@aura/domain/view-mode" + +const tabClass = + "border-b-2 border-transparent px-1 pb-2 text-muted-foreground transition-colors" +const activeTabClass = "!border-primary !text-foreground" + +/** Shared chrome for a view's tabs (header, profile, evaluate/levelup nav). */ +export default function HomeViewLayout(props: ParentProps) { + const navigate = useNavigate() + const params = useParams() + const subjectId = useRequireSession() + + // Reject unknown view slugs. + createEffect(() => { + if (subjectId() && params.view && !(params.view in viewSlugToViewMode)) { + navigate("/home", { replace: true }) + } + }) + + const vm = useViewMode() + const levelup = useLevelupProgress(vm.currentRoleEvaluatorEvaluationCategory) + const base = () => `/home/${params.view}` + + // 404 on profile/connections = the node hasn't seen this user yet — a + // profile only gets created when someone evaluates them. + const profile = createBrightIdProfileQuery(() => subjectId() ?? "") + const inbound = createInboundConnectionsQuery(() => subjectId() ?? "") + const profileMissing = () => + isNotFound(profile.error) || isNotFound(inbound.error) + + return ( + Not logged in}> +
+ + + +
+ +
+
+
+ + + + +
{props.children}
+
+ + ) +} diff --git a/apps/core/src/routes/home/[view]/index.tsx b/apps/core/src/routes/home/[view]/index.tsx new file mode 100644 index 0000000..b481bf4 --- /dev/null +++ b/apps/core/src/routes/home/[view]/index.tsx @@ -0,0 +1,41 @@ +import { createSignal, For, Show } from "solid-js" +import EvaluateModal from "@/components/evaluation/evaluate-modal" +import SubjectCard from "@/components/home/subject-card" +import SubjectListControls from "@/components/home/subject-list-controls" +import { useSubjectsList } from "@/hooks/use-subjects-list" + +export default function HomeEvaluate() { + const controls = useSubjectsList() + const { subjects, loading } = controls + const [evaluating, setEvaluating] = createSignal(null) + + return ( +
+ + Loading…
} + > + 0} + fallback={ +
+ No connections to evaluate yet. +
+ } + > + + {(connection) => ( + + )} + +
+ + + setEvaluating(null)} /> +
+ ) +} diff --git a/apps/core/src/routes/home/[view]/levelup.tsx b/apps/core/src/routes/home/[view]/levelup.tsx new file mode 100644 index 0000000..ffa8705 --- /dev/null +++ b/apps/core/src/routes/home/[view]/levelup.tsx @@ -0,0 +1,66 @@ +import { Show } from "solid-js" +import LevelProgress from "@/components/home/level-progress" +import { useRequireSession } from "@/hooks/use-require-session" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { useViewMode } from "@/hooks/use-view-mode" +import { compactFormat } from "@/shared/lib/number" + +/** Single "Evaluations" stat row: title, value, and an optional detail note. */ +function StatRow(props: { title: string; value: string; details?: string }) { + return ( +
+
{props.title}:
+
+ {props.value} + + {props.details} + +
+
+ ) +} + +/** /home/:view/levelup — next-level progress + inbound evaluation summary. */ +export default function HomeLevelUp() { + const subjectId = useRequireSession() + const vm = useViewMode() + const v = useSubjectVerifications( + () => subjectId() ?? "", + vm.currentRoleEvaluatorEvaluationCategory, + ) + + const impacts = () => (v.auraImpacts() ?? []).filter((i) => i.impact !== 0) + const evaluationsCount = () => impacts().length + const totalPositive = () => + impacts() + .filter((i) => i.impact > 0) + .reduce((sum, i) => sum + i.impact, 0) + const totalNegative = () => + impacts() + .filter((i) => i.impact < 0) + .reduce((sum, i) => sum + Math.abs(i.impact), 0) + + return ( + + {(id) => ( +
+ + +
+
Evaluations
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/apps/core/src/routes/home/index.tsx b/apps/core/src/routes/home/index.tsx new file mode 100644 index 0000000..45439c8 --- /dev/null +++ b/apps/core/src/routes/home/index.tsx @@ -0,0 +1,6 @@ +import { Navigate } from "@solidjs/router" + +/** /home → default view. */ +export default function HomeIndex() { + return +} diff --git a/apps/core/src/routes/index.tsx b/apps/core/src/routes/index.tsx index 8bfb9e7..3e50457 100644 --- a/apps/core/src/routes/index.tsx +++ b/apps/core/src/routes/index.tsx @@ -2,7 +2,7 @@ import FadeIn from "@/components/motions/fade-in" import Scale from "@/components/motions/scale" import { A } from "@solidjs/router" -const Spalsh = () => { +const Splash = () => { return (
@@ -41,13 +41,13 @@ const Spalsh = () => {
-
+
Version - 2.1 + {__APP_VERSION__} - Powered by: + Powered by: BrightID
@@ -56,4 +56,4 @@ const Spalsh = () => { ) } -export default Spalsh +export default Splash diff --git a/apps/core/src/routes/login.tsx b/apps/core/src/routes/login.tsx index 941d7db..9ddde7d 100644 --- a/apps/core/src/routes/login.tsx +++ b/apps/core/src/routes/login.tsx @@ -4,12 +4,19 @@ import { createMutation, createQuery } from "@tanstack/solid-query" import QRCode from "qrcode" import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import FadeIn from "@/components/motions/fade-in" -import { generateB64Keypair, hash, urlSafeRandomKey } from "@/shared/lib/crypto" +import { generateB64Keypair, hash, urlSafeRandomKey } from "@aura/domain/crypto" +import { + createPasskeyIdentity, + getPasskeyIdentity, + hasPasskeyCredential, + type PasskeyIdentity, +} from "@aura/domain/passkeys" import { buildRecoveryChannelQrUrl, + CHANNEL_POLL_INTERVAL, pollRecoveredUser, uploadRecoveryData, -} from "@/shared/lib/recovery" +} from "@aura/domain/recovery" import { AURA_NODE_URL, AURA_NODE_URL_PROXY } from "@/shared/lib/urls" import { authStore, setAuthStore, setKeypair } from "@/store/auth" import { @@ -23,7 +30,6 @@ import { } from "@/store/recovery" const QR_SIZE = 270 -const POLL_INTERVAL = 3000 export default function LoginPage() { const navigate = useNavigate() @@ -72,6 +78,7 @@ export default function LoginPage() { if (user?.id && user.password) { // recovered user → auth store is the source of truth setAuthStore("user", { brightId: user.id, password: user.password }) + setAuthStore("authMethod", "brightid") setRecovered(true) } return user ?? null @@ -80,10 +87,41 @@ export default function LoginPage() { recoveryStore.recoverStep === "INITIALIZED" && !!recoveryStore.channel.url && !recovered(), - refetchInterval: POLL_INTERVAL, + refetchInterval: CHANNEL_POLL_INTERVAL, retry: false, })) + // Passkey login: derive a deterministic Ed25519 identity from the passkey's + // PRF output — same passkey, same identity, no channel or backup involved. + function completePasskeyLogin(identity: PasskeyIdentity) { + setKeypair(identity.privateKey, identity.publicKey) + setAuthStore("user", { brightId: identity.id, password: "" }) + setAuthStore("authMethod", "passkey") + // Drop the half-open recovery channel keyed to the previous keypair + resetRecovery() + const next = typeof params.next === "string" ? params.next : "/home" + navigate(next, { replace: true }) + } + + const passkeyError = (title: string) => (e: unknown) => + toast.error(title, { + description: e instanceof Error ? e.message : String(e), + }) + + const passkeySignIn = createMutation(() => ({ + mutationFn: getPasskeyIdentity, + onSuccess: completePasskeyLogin, + onError: passkeyError("Passkey sign-in failed"), + })) + + const passkeyCreate = createMutation(() => ({ + mutationFn: () => createPasskeyIdentity("Aura"), + onSuccess: completePasskeyLogin, + onError: passkeyError("Could not create a passkey"), + })) + + const passkeyBusy = () => passkeySignIn.isPending || passkeyCreate.isPending + const monthYear = new Date().toLocaleString("en-US", { month: "short", year: "numeric", @@ -122,7 +160,7 @@ export default function LoginPage() { createEffect(() => { if (!recovered()) return setImporting(true) - const next = typeof params.next === "string" ? params.next : "/" + const next = typeof params.next === "string" ? params.next : "/home" resetRecovery() navigate(next, { replace: true }) }) @@ -206,7 +244,7 @@ export default function LoginPage() {
- + {universalLink()} @@ -222,17 +260,44 @@ export default function LoginPage() { + +
+
+ Or +
+
+ + !passkeyBusy() && passkeySignIn.mutate()} + > + {passkeySignIn.isPending + ? "Waiting for passkey..." + : "Sign in with passkey"} + +
-
+
Version - 2.1 + {__APP_VERSION__} - Powered by: + Powered by: BrightID
diff --git a/apps/core/src/routes/notifications.tsx b/apps/core/src/routes/notifications.tsx new file mode 100644 index 0000000..2759086 --- /dev/null +++ b/apps/core/src/routes/notifications.tsx @@ -0,0 +1,152 @@ +import { useNavigate } from "@solidjs/router" +import { createMemo, createSignal, For, Show } from "solid-js" +import { useNameResolver } from "@/hooks/use-backup" +import { useRequireSession } from "@/hooks/use-require-session" +import { + markAllAsRead, + markAsRead, + notificationsStore, + unreadCount, +} from "@/store/notifications" +import { compactFormat } from "@/shared/lib/number" +import { formatDuration } from "@/shared/lib/time" +import { categoryLabel } from "@aura/domain/labels" +import type { NotificationAlert } from "@aura/domain/notifications" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +const CATEGORY_FILTERS: { label: string; value: EvaluationCategory | null }[] = [ + { label: "All", value: null }, + ...Object.values(EvaluationCategory).map((c) => ({ + label: categoryLabel[c], + value: c as EvaluationCategory | null, + })), +] + +const KIND_ICON: Record = { + evaluation: "user-check", + "score-increase": "trending-up", + "score-decrease": "trending-down", + "level-increase": "arrow-up", + "level-decrease": "arrow-down", +} + +/** /notifications — alerts produced by the notifications checker. */ +export default function NotificationsPage() { + const subjectId = useRequireSession() + const navigate = useNavigate() + const resolveName = useNameResolver() + const [filter, setFilter] = createSignal(null) + + const nameOf = (id: string) => + id === subjectId() ? "You" : resolveName(id) + + const title = (a: NotificationAlert) => { + switch (a.kind) { + case "evaluation": + return a.to === subjectId() + ? `${nameOf(a.about)} evaluated you` + : `${nameOf(a.about)} evaluated ${nameOf(a.to ?? "")}` + case "level-increase": + return a.about === subjectId() + ? `Your level increased to ${a.next}` + : `${nameOf(a.about)} leveled up to ${a.next}` + case "level-decrease": + return a.about === subjectId() + ? `Your level decreased to ${a.next}` + : `${nameOf(a.about)}'s level decreased to ${a.next}` + case "score-increase": + return a.about === subjectId() + ? `Your score increased to ${compactFormat(a.next ?? 0)}` + : `${nameOf(a.about)}'s score increased to ${compactFormat(a.next ?? 0)}` + case "score-decrease": + return a.about === subjectId() + ? `Your score dropped to ${compactFormat(a.next ?? 0)}` + : `${nameOf(a.about)}'s score dropped to ${compactFormat(a.next ?? 0)}` + } + } + + const alerts = createMemo(() => { + const f = filter() + return notificationsStore.alerts.filter((a) => !f || a.category === f) + }) + + const open = (a: NotificationAlert) => { + markAsRead(a.id) + const target = a.about === subjectId() ? a.to : a.about + if (target) navigate(`/subject/${target}`) + } + + return ( +
+
+ Notifications + 0}> + + Mark all as read + + +
+ +
+ + {(c) => ( + setFilter(c.value)} + > + {c.label} + + )} + +
+ + 0} + fallback={ +
+ Nothing here yet — new evaluations and level or score changes will + show up here. +
+ } + > +
+ + {(alert) => ( + open(alert)} + > + +
+

+ {title(alert)} +

+

+ {alert.category} ·{" "} + {formatDuration(alert.timestamp)} +

+
+ + + +
+ )} +
+
+
+
+ ) +} diff --git a/apps/core/src/routes/onboarding.tsx b/apps/core/src/routes/onboarding.tsx new file mode 100644 index 0000000..7c9092a --- /dev/null +++ b/apps/core/src/routes/onboarding.tsx @@ -0,0 +1,111 @@ +import { useNavigate, useSearchParams } from "@solidjs/router" +import { createMemo, For, Show } from "solid-js" +import FadeIn from "@/components/motions/fade-in" +import { useRequireSession } from "@/hooks/use-require-session" +import { setOnboardingStore } from "@/store/onboarding" + +/** Copy ported from the old app's playerOnboarding translations. */ +const STEPS: { title: string; description: string }[] = [ + { + title: "Find a Subject", + description: + "Find your friends and family in your BrightID connections to help verify them", + }, + { + title: "Gather Information", + description: + "Gather information to help you to make an accurate evaluation — see who has connected to the subject in BrightID and how other Aura players have evaluated them", + }, + { + title: "Evaluate the Subject", + description: + "Once you have enough information, tell other players what you think about the subject and how confident you are about your answer", + }, + { + title: "Level up", + description: + "To be effective at helping others get verified, you'll need to reach higher levels. Reach level 1+ by playing well and finding trainers to evaluate your play.", + }, +] + +/** + * /onboarding — 4-step tutorial. Step lives in the URL (?step=1..4); the old + * Swiper carousel is replaced by simple step navigation (no new deps). + */ +export default function OnboardingPage() { + useRequireSession() + const navigate = useNavigate() + const [params, setParams] = useSearchParams() + + const step = createMemo(() => { + const n = Number(params.step) + return Number.isInteger(n) && n >= 1 && n <= STEPS.length ? n : 1 + }) + const current = () => STEPS[step() - 1] + const isLast = () => step() === STEPS.length + + const goTo = (n: number) => setParams({ step: String(n) }) + + const next = () => { + if (!isLast()) return goTo(step() + 1) + setOnboardingStore("onboardingShown", true) + navigate("/home") + } + + return ( +
+
+ + {current().title} + + + + {current().description} + + +
+ +
+
+ + {(_, i) => ( +
+ + + + Let's Start + + +
+ +
+ + Powered by: + BrightID + +
+
+ ) +} diff --git a/apps/core/src/routes/role-management.tsx b/apps/core/src/routes/role-management.tsx new file mode 100644 index 0000000..2a14337 --- /dev/null +++ b/apps/core/src/routes/role-management.tsx @@ -0,0 +1,81 @@ +import { For, Show } from "solid-js" +import LevelScore from "@/components/shared/level-score" +import { useLevelupProgress } from "@/hooks/use-levelup-progress" +import { useRequireSession } from "@/hooks/use-require-session" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +const ROLES: { + label: string + category: EvaluationCategory + /** Roles past Player must be unlocked via level-up progress. */ + gated: boolean +}[] = [ + { label: "Player", category: EvaluationCategory.PLAYER, gated: false }, + { label: "Trainer", category: EvaluationCategory.TRAINER, gated: true }, + { label: "Manager", category: EvaluationCategory.MANAGER, gated: true }, +] + +/** + * One card per role with level/score and unlock state. The old app's three + * near-identical role-card components collapsed into this one. + */ +function RoleCard(props: { + subjectId: () => string + label: string + category: EvaluationCategory + gated: boolean +}) { + const v = useSubjectVerifications(props.subjectId, () => props.category) + const progress = useLevelupProgress(() => props.category) + const unlocked = () => !props.gated || progress().isUnlocked + + return ( + +
+

{props.label}

+ + Locked + + } + > + + Unlocked + + +
+ +
+ ) +} + +/** /role-management — level, score and unlock state for each role. */ +export default function RoleManagementPage() { + const subjectId = useRequireSession() + + return ( +
+ Role Management + + + + {(role) => ( + subjectId()!} + label={role.label} + category={role.category} + gated={role.gated} + /> + )} + + +
+ ) +} diff --git a/apps/core/src/routes/settings.tsx b/apps/core/src/routes/settings.tsx new file mode 100644 index 0000000..9812a79 --- /dev/null +++ b/apps/core/src/routes/settings.tsx @@ -0,0 +1,58 @@ +import LogoutButton from "@/components/settings/logout-button" +import SettingCard from "@/components/settings/setting-card" +import ThemeToggle from "@/components/settings/theme-toggle" +import VersionCard from "@/components/settings/version-card" + +/** + * /settings — ported from the React app, same card order. + * + * Not ported: the decorative three.js sphere (would pull three+gsap+glsl into + * the bundle) and the PWA service-worker update flow in the version card. + * Contact info / Role Management / Onboarding link to their source paths — + * those routes are still to be migrated. + */ +export default function SettingsPage() { + return ( +
+ Settings + +
+ + + + + + + + + + + + +
+
+ ) +} diff --git a/apps/core/src/routes/subject/[id]/index.tsx b/apps/core/src/routes/subject/[id]/index.tsx new file mode 100644 index 0000000..a7b659a --- /dev/null +++ b/apps/core/src/routes/subject/[id]/index.tsx @@ -0,0 +1,304 @@ +import { A, useParams, useSearchParams } from "@solidjs/router" +import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" +import EvaluationsChart from "@/components/charts/evaluations-chart" +import CredibilityDetails from "@/components/evaluation/credibility-details" +import EvaluateModal from "@/components/evaluation/evaluate-modal" +import EvaluationCard from "@/components/evaluation/evaluation-card" +import LevelProgress from "@/components/home/level-progress" +import ProfileNotFoundHint from "@/components/home/profile-not-found-hint" +import ListState from "@/components/list/list-state" +import ConnectionCard from "@/components/subject/connection-card" +import EvidenceHelp from "@/components/subject/evidence-help" +import SubjectProfileCard from "@/components/subject/subject-profile-card" +import { useRequireSession } from "@/hooks/use-require-session" +import { + useSubjectInboundConnections, + useSubjectInboundEvaluations, + useSubjectOutboundEvaluations, +} from "@/hooks/use-subject-inbound-evaluations" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { useViewMode } from "@/hooks/use-view-mode" +import { authStore } from "@/store/auth" +import { isNotFound } from "@aura/domain/http" +import { categoryLabel } from "@aura/domain/labels" +import { PreferredView } from "@aura/domain/types/dashboard" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +type ProfileTab = "overview" | "evaluations" | "connections" | "activity" + +/** Which category an "Activity" tab shows per view (one role below). */ +const ACTIVITY_CATEGORY: Partial> = { + [PreferredView.TRAINER]: EvaluationCategory.SUBJECT, + [PreferredView.MANAGER_EVALUATING_TRAINER]: EvaluationCategory.PLAYER, + [PreferredView.MANAGER_EVALUATING_MANAGER]: EvaluationCategory.TRAINER, +} + +/** + * Subject profile — ported from the old app's `_app.subject.$id`: a single + * page with a view-as switcher, profile info (incl. your own evaluation) and + * the Evidence tabs (Overview / Connections-or-Activity / Evaluations). + * Dropped from the old page: the player-history breadcrumb sequence and the + * manager-on-manager extra activity tab. + */ +export default function SubjectPage() { + const params = useParams() + useRequireSession() + const [query, setQuery] = useSearchParams() + + const subjectId = () => params.id ?? "" + const vm = useViewMode() + const category = vm.currentEvaluationCategory + const v = useSubjectVerifications(subjectId, category) + + const [evaluating, setEvaluating] = createSignal(null) + const [detailsId, setDetailsId] = createSignal(null) + const [search, setSearch] = createSignal("") + + // ── Tabs (tracked in ?tab=, like the old app) ─────────── + const isPlayerView = () => vm.currentViewMode() === PreferredView.PLAYER + const tab = createMemo(() => { + const t = query.tab + if (t === "evaluations" || t === "overview") return t + if (t === "connections" && isPlayerView()) return t + if (t === "activity" && !isPlayerView()) return t + return "overview" + }) + const setTab = (t: ProfileTab) => { + setSearch("") + setQuery({ tab: t }) + } + // Leaving the view that owns the current tab resets to overview. + createEffect(() => { + if (query.tab === "connections" && !isPlayerView()) setTab("overview") + if (query.tab === "activity" && isPlayerView()) setTab("overview") + }) + + // ── Data ──────────────────────────────────────────────── + const inbound = useSubjectInboundEvaluations(subjectId, category) + const connections = useSubjectInboundConnections(subjectId) + const activity = useSubjectOutboundEvaluations( + subjectId, + () => ACTIVITY_CATEGORY[vm.currentViewMode()] ?? EvaluationCategory.SUBJECT, + ) + + // View-as options: Subject always; a role once the subject has activity in + // it (same gating as the old header's authorized tabs). + const activityIn = { + [EvaluationCategory.PLAYER]: useSubjectOutboundEvaluations(subjectId, () => EvaluationCategory.PLAYER), + [EvaluationCategory.TRAINER]: useSubjectOutboundEvaluations(subjectId, () => EvaluationCategory.TRAINER), + [EvaluationCategory.MANAGER]: useSubjectOutboundEvaluations(subjectId, () => EvaluationCategory.MANAGER), + } + const authorizedViewAs = createMemo(() => + Object.values(EvaluationCategory).filter( + (c) => + c === EvaluationCategory.SUBJECT || + (activityIn[c as keyof typeof activityIn].evaluations()?.length ?? 0) > 0, + ), + ) + + const profileMissing = () => isNotFound(v.query.error) + const isSelf = () => subjectId() === authStore.user?.brightId + + // ── Search (shared signal, cleared on tab switch) ─────── + const matches = (id: string, name: string) => { + const q = search().trim().toLowerCase() + return !q || name.toLowerCase().includes(q) || id.toLowerCase().includes(q) + } + const filteredEvaluations = createMemo( + () => inbound.evaluations()?.filter((e) => matches(e.evaluatorId, e.name)) ?? [], + ) + const filteredActivity = createMemo( + () => activity.evaluations()?.filter((e) => matches(e.evaluatorId, e.name)) ?? [], + ) + const filteredConnections = createMemo( + () => + connections + .connections() + ?.filter((c) => matches(c.id, connections.nameOf(c.id))) ?? [], + ) + + // Component (not a shared element) — each panel needs its own DOM node. + const SearchInput = () => ( + ) => setSearch(e.detail)} + > + + + ) + + return ( +
+ {/* ── Header: title + view-as switcher ── */} +
+
+ + ← Back + + {vm.subjectViewModeTitle()} Profile +
+
+ + {(c) => ( + vm.updateViewAs(c)} + > + {categoryLabel[c]} + + )} + +
+
+ + setEvaluating(subjectId())} + /> + + + + + + {/* ── Evidence tabs ── */} + + ) => + setTab(e.detail.value as ProfileTab) + } + > + Overview + Activity} + > + Connections + + Evaluations + + +
+ +
+ + {inbound.positiveCount() ?? "-"} + + Positive +
+
+ + {inbound.negativeCount() ?? "-"} + + Negative +
+
+ + {inbound.evaluations()?.length ?? "-"} + + Total +
+
+ + +

+ Evaluation impacts — tap a bar for details +

+ v.auraImpacts()} + onBarClick={setDetailsId} + /> +
+ + + + + + setTab("evaluations")} + > + View evaluations + +
+
+ + +
+ + + + {(evaluation) => ( + + )} + + +
+
+ + + + +
+ + + + {(connection) => ( + + )} + + +
+
+
+ + +
+ + + + {(evaluation) => ( + + )} + + +
+
+
+
+
+ + setEvaluating(null)} /> + setDetailsId(null)} /> +
+ ) +} diff --git a/apps/core/src/shared/lib/api.ts b/apps/core/src/shared/lib/api.ts new file mode 100644 index 0000000..a24d9aa --- /dev/null +++ b/apps/core/src/shared/lib/api.ts @@ -0,0 +1,6 @@ +import { AURA_NODE_URL_PROXY, RECOVERY_URL_PROXY } from "@/shared/lib/urls"; + +export const NODE_API_BASE = AURA_NODE_URL_PROXY; +export const RECOVERY_API_BASE = RECOVERY_URL_PROXY; + +export { getJson, getText, postJson } from "@aura/domain/http"; diff --git a/apps/core/src/shared/lib/score.ts b/apps/core/src/shared/lib/score.ts deleted file mode 100644 index e69de29..0000000 diff --git a/apps/core/src/shared/lib/url-defaults.ts b/apps/core/src/shared/lib/url-defaults.ts new file mode 100644 index 0000000..4b72158 --- /dev/null +++ b/apps/core/src/shared/lib/url-defaults.ts @@ -0,0 +1,7 @@ +export const DEFAULT_AURA_NODE_URL = "https://aura-node.brightid.org"; +export const DEFAULT_AURA_TEST_NODE_URL = "https://aura-test.brightid.org"; +export const DEFAULT_RECOVERY_URL = "https://recovery.brightid.org"; + +export const AURA_NODE_PROXY_PATH = "/auranode"; +export const AURA_TEST_NODE_PROXY_PATH = "/auranode-test"; +export const RECOVERY_PROXY_PATH = "/brightid"; diff --git a/apps/core/src/shared/lib/urls.ts b/apps/core/src/shared/lib/urls.ts index ed619ff..20e1d00 100644 --- a/apps/core/src/shared/lib/urls.ts +++ b/apps/core/src/shared/lib/urls.ts @@ -1,7 +1,20 @@ -import { IS_PRODUCTION } from './env'; +import { IS_PRODUCTION } from "./env"; +import { + AURA_NODE_PROXY_PATH, + AURA_TEST_NODE_PROXY_PATH, + DEFAULT_AURA_NODE_URL, + DEFAULT_AURA_TEST_NODE_URL, + RECOVERY_PROXY_PATH, +} from "./url-defaults"; -export const AURA_NODE_URL_PROXY = `/auranode${IS_PRODUCTION ? '' : '-test'}`; +const env = import.meta.env; -export const AURA_NODE_URL = IS_PRODUCTION - ? 'https://aura-node.brightid.org' - : 'https://aura-test.brightid.org'; +export const AURA_NODE_URL_PROXY = IS_PRODUCTION + ? AURA_NODE_PROXY_PATH + : AURA_TEST_NODE_PROXY_PATH; + +export const AURA_NODE_URL: string = IS_PRODUCTION + ? (env.VITE_AURA_NODE_URL ?? DEFAULT_AURA_NODE_URL) + : (env.VITE_AURA_TEST_NODE_URL ?? DEFAULT_AURA_TEST_NODE_URL); + +export const RECOVERY_URL_PROXY = RECOVERY_PROXY_PATH; diff --git a/apps/core/src/shared/types.ts b/apps/core/src/shared/types.ts deleted file mode 100644 index ccd1261..0000000 --- a/apps/core/src/shared/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** Decrypted BrightID backup blob. Shape is intentionally loose — the backup - * carries arbitrary user data restored from the recovery channel. */ -export interface BrightIdBackup { - [key: string]: unknown; -} diff --git a/apps/core/src/store/auth.ts b/apps/core/src/store/auth.ts index 249902f..c76bb18 100644 --- a/apps/core/src/store/auth.ts +++ b/apps/core/src/store/auth.ts @@ -1,24 +1,26 @@ import { makePersisted } from "@solid-primitives/storage" import { createStore } from "solid-js/store" +export type AuthMethod = "brightid" | "passkey" + export interface AuthState { user: { brightId: string + /** Empty for passkey sessions — there is no BrightID backup to decrypt. */ password: string } | null + /** How the current session was established. */ + authMethod: AuthMethod | null + publicKey: string secretKey: string - - key: string | null - backupEncrypted: string | null } const [authStore, setAuthStore] = makePersisted( createStore({ user: null, - key: null, - backupEncrypted: null, + authMethod: null, publicKey: "", secretKey: "", }), @@ -28,4 +30,14 @@ export function setKeypair(secretKey: string, publicKey: string): void { setAuthStore((prev) => ({ ...prev, secretKey, publicKey })) } +/** Clear the session (user + keys). */ +export function logout(): void { + setAuthStore({ + user: null, + authMethod: null, + publicKey: "", + secretKey: "", + }) +} + export { authStore, setAuthStore } diff --git a/apps/core/src/store/contacts.ts b/apps/core/src/store/contacts.ts new file mode 100644 index 0000000..31c4bc4 --- /dev/null +++ b/apps/core/src/store/contacts.ts @@ -0,0 +1,27 @@ +import { makePersisted } from "@solid-primitives/storage" +import { createStore } from "solid-js/store" + +export type ContactType = "email" | "phone" + +export interface StoredContact { + type: ContactType + /** bcrypt hash of the normalized value — the raw contact is never stored. */ + hash: string +} + +export interface ContactsState { + stored: StoredContact[] +} + +const [contactsStore, setContactsStore] = makePersisted( + createStore({ + stored: [], + }), +) + +export function addContact(contact: StoredContact): void { + if (contactsStore.stored.some((c) => c.hash === contact.hash)) return + setContactsStore("stored", (prev) => [...prev, contact]) +} + +export { contactsStore, setContactsStore } diff --git a/apps/core/src/store/notifications.ts b/apps/core/src/store/notifications.ts new file mode 100644 index 0000000..78c0a44 --- /dev/null +++ b/apps/core/src/store/notifications.ts @@ -0,0 +1,51 @@ +import { makePersisted } from "@solid-primitives/storage" +import { createStore } from "solid-js/store" +import type { + NotificationAlert, + TrackedProfiles, +} from "@aura/domain/notifications" + +const MAX_ALERTS = 100 + +export interface NotificationsState { + tracked: TrackedProfiles + alerts: NotificationAlert[] + lastFetch: number | null +} + +const [notificationsStore, setNotificationsStore] = makePersisted( + createStore({ + tracked: {}, + alerts: [], + lastFetch: null, + }), +) + +/** Merge a diff result: dedupe by id, newest first, capped. */ +export function ingestNotifications( + result: { alerts: NotificationAlert[]; tracked: TrackedProfiles }, + now: number, +): void { + setNotificationsStore((prev) => { + const known = new Set(prev.alerts.map((a) => a.id)) + const fresh = result.alerts.filter((a) => !known.has(a.id)) + const alerts = [...fresh, ...prev.alerts] + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, MAX_ALERTS) + return { ...prev, alerts, tracked: result.tracked, lastFetch: now } + }) +} + +export function markAsRead(id: string): void { + setNotificationsStore("alerts", (a) => a.id === id, "viewed", true) +} + +export function markAllAsRead(): void { + setNotificationsStore("alerts", {}, "viewed", true) +} + +export function unreadCount(): number { + return notificationsStore.alerts.filter((a) => !a.viewed).length +} + +export { notificationsStore, setNotificationsStore } diff --git a/apps/core/src/store/operations.ts b/apps/core/src/store/operations.ts index 39eebbf..96e0b9e 100644 --- a/apps/core/src/store/operations.ts +++ b/apps/core/src/store/operations.ts @@ -1,6 +1,9 @@ import { makePersisted } from "@solid-primitives/storage" import { createStore } from "solid-js/store" -import type { EvaluateOperation } from "@/types/evaluations" +import type { + EvaluateOperation, + OperationState, +} from "@aura/domain/types/evaluations" export interface OperationsState { byHash: Record @@ -12,4 +15,34 @@ const [operationsStore, setOperationsStore] = makePersisted( }), ) +const TERMINAL_STATES: OperationState[] = ["APPLIED", "FAILED", "EXPIRED"] +const OPERATION_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +// The store is persisted and ops were never removed — prune finished ones on +// startup so it doesn't grow unbounded across sessions. +{ + const cutoff = Date.now() - OPERATION_RETENTION_MS + const stale = Object.values(operationsStore.byHash).filter( + (op) => + TERMINAL_STATES.includes(op.state) && + (op.postTimestamp ?? op.timestamp) < cutoff, + ) + if (stale.length) + setOperationsStore("byHash", (prev) => { + const next = { ...prev } + for (const op of stale) delete next[op.hash] + return next + }) +} + +/** Insert or replace an operation, keyed by its hash. */ +export function upsertOperation(op: EvaluateOperation): void { + setOperationsStore("byHash", op.hash, op) +} + +/** Update the tracked state of an existing operation. */ +export function setOperationState(hash: string, state: OperationState): void { + setOperationsStore("byHash", hash, "state", state) +} + export { operationsStore, setOperationsStore } diff --git a/apps/core/src/store/preferences.ts b/apps/core/src/store/preferences.ts index 0c10bb4..bd8b284 100644 --- a/apps/core/src/store/preferences.ts +++ b/apps/core/src/store/preferences.ts @@ -13,15 +13,29 @@ export interface PreferencesState { theme: Theme } +const DEFAULTS: PreferencesState = { + baseUrl: AURA_NODE_URL_PROXY, + nodeUrls: [AURA_NODE_URL_PROXY], + isPrimaryDevice: true, + lastSyncTime: 0, + languageTag: null, + theme: "dark", +} + const [preferencesStore, setPreferencesStore] = makePersisted( - createStore({ - baseUrl: AURA_NODE_URL_PROXY, - nodeUrls: [AURA_NODE_URL_PROXY], - isPrimaryDevice: true, - lastSyncTime: 0, - languageTag: null, - theme: "dark", - }), + createStore({ ...DEFAULTS }), ) +// A persisted snapshot from an older app version replaces the defaults +// wholesale, so fields added since then hydrate as undefined — backfill them. +for (const key of Object.keys(DEFAULTS) as (keyof PreferencesState)[]) { + if (preferencesStore[key] === undefined) { + setPreferencesStore(key, DEFAULTS[key] as never) + } +} + +export function setTheme(theme: Theme): void { + setPreferencesStore("theme", theme) +} + export { preferencesStore, setPreferencesStore } diff --git a/apps/core/vercel.json b/apps/core/vercel.json new file mode 100644 index 0000000..94f2bf4 --- /dev/null +++ b/apps/core/vercel.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "rewrites": [ + { + "source": "/brightid/:path*", + "destination": "https://recovery.brightid.org/:path*" + }, + { + "source": "/brightid/:path*/", + "destination": "https://recovery.brightid.org/:path*/" + }, + { + "source": "/auranode/:path*", + "destination": "https://aura-node.brightid.org/:path*" + }, + { + "source": "/auranode/:path*/", + "destination": "https://aura-node.brightid.org/:path*/" + }, + { + "source": "/auranode-test/:path*", + "destination": "https://aura-test.brightid.org/:path*" + }, + { + "source": "/auranode-test/:path*/", + "destination": "https://aura-test.brightid.org/:path*/" + }, + { + "source": "/(.*)", + "destination": "/" + } + ], + + "installCommand": "bun install", + "buildCommand": "bun run build", + "outputDirectory": "dist" +} diff --git a/apps/core/vite.config.ts b/apps/core/vite.config.ts index 0cb4590..863711d 100644 --- a/apps/core/vite.config.ts +++ b/apps/core/vite.config.ts @@ -1,32 +1,58 @@ import { resolve } from 'node:path'; import tailwindcss from '@tailwindcss/vite'; import solid from 'vite-plugin-solid'; -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; +import pkg from './package.json'; +import { + AURA_NODE_PROXY_PATH, + AURA_TEST_NODE_PROXY_PATH, + DEFAULT_AURA_NODE_URL, + DEFAULT_AURA_TEST_NODE_URL, + DEFAULT_RECOVERY_URL, + RECOVERY_PROXY_PATH, +} from './src/shared/lib/url-defaults'; -export default defineConfig({ - plugins: [tailwindcss(), solid()], - resolve: { - alias: { - '@': resolve(__dirname, './src'), +const stripPrefix = (prefix: string) => (path: string) => + path.replace(new RegExp(`^${prefix}`), ''); + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, ''); + + return { + plugins: [tailwindcss(), solid()], + define: { + __APP_VERSION__: JSON.stringify(pkg.version), }, - }, - server: { - // Proxy the recovery channel/profile calls to the BrightID aura nodes to - // avoid CORS during dev. `AURA_NODE_URL_PROXY` (`/auranode[-test]`) is used - // as the channel base url; strip the prefix and forward to the real node. - proxy: { - '/auranode-test': { - target: 'https://aura-test.brightid.org', - changeOrigin: true, - secure: true, - rewrite: (path) => path.replace(/^\/auranode-test/, ''), + resolve: { + alias: { + '@': resolve(__dirname, './src'), }, - '/auranode': { - target: 'https://aura-node.brightid.org', - changeOrigin: true, - secure: true, - rewrite: (path) => path.replace(/^\/auranode/, ''), + }, + server: { + // Proxy node + recovery calls to avoid CORS during dev. Targets are + // env-overridable; defaults live in src/shared/lib/url-defaults.ts. + // vercel.json mirrors these rewrites for production. + proxy: { + [AURA_TEST_NODE_PROXY_PATH]: { + target: env.VITE_AURA_TEST_NODE_URL ?? DEFAULT_AURA_TEST_NODE_URL, + changeOrigin: true, + secure: true, + rewrite: stripPrefix(AURA_TEST_NODE_PROXY_PATH), + }, + [AURA_NODE_PROXY_PATH]: { + target: env.VITE_AURA_NODE_URL ?? DEFAULT_AURA_NODE_URL, + changeOrigin: true, + secure: true, + rewrite: stripPrefix(AURA_NODE_PROXY_PATH), + }, + // BrightID recovery service — encrypted backup downloads + [RECOVERY_PROXY_PATH]: { + target: env.VITE_RECOVERY_URL ?? DEFAULT_RECOVERY_URL, + changeOrigin: true, + secure: true, + rewrite: stripPrefix(RECOVERY_PROXY_PATH), + }, }, }, - }, + }; }); diff --git a/apps/interface/src/utils/apis/index.ts b/apps/interface/src/utils/apis/index.ts index 8bd393f..53b4f10 100644 --- a/apps/interface/src/utils/apis/index.ts +++ b/apps/interface/src/utils/apis/index.ts @@ -32,3 +32,39 @@ export const getProjects = async () => { return (res.data! ?? []) as Project[] } + +export interface VerificationSignature { + r: string + s: string + v: number +} + +export interface VerifyProjectResult { + userId: string + projectId: number + client: string + signature: VerificationSignature + auraScore?: number + auraLevel?: number + verifiedAt: string +} + +/** + * Calls the interface app's verify endpoint to generate a verification + * signature for a verified user. Returns the signature payload on success. + */ +export const verifyProject = async ( + projectId: number, + payload: { userId: string; client: string; auraScore?: number; auraLevel?: number } +) => { + const res = await clientAPI.POST('/projects/{id}/verify' as never, { + params: { path: { id: String(projectId) } }, + body: payload + } as never) + + if ((res as { error?: unknown }).error) { + throw new Error('Failed to generate verification signature') + } + + return (res as { data?: { data?: VerifyProjectResult } }).data?.data +} diff --git a/bun.lock b/bun.lock index c7641c2..c72fdb3 100644 --- a/bun.lock +++ b/bun.lock @@ -164,15 +164,18 @@ }, "apps/core": { "name": "@aura/core", - "version": "0.0.1", + "version": "2.1.0", "dependencies": { + "@aura/domain": "workspace:*", "@aura/ui": "workspace:*", "@solid-primitives/storage": "^4.3.4", "@solidjs/router": "^0.15.3", + "@tanstack/query-async-storage-persister": "^5.100.14", "@tanstack/solid-query": "^5.100.14", - "base64-js": "^1.5.1", - "crypto-js": "^4.2.0", + "@tanstack/solid-query-persist-client": "^5.100.14", + "bcryptjs": "^3.0.3", "libphonenumber-js": "^1.13.3", + "localforage": "^1.10.0", "qrcode": "^1.5.4", "solid-js": "^1.9.5", "solid-motionone": "^1.0.4", @@ -180,7 +183,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", - "@types/crypto-js": "^4.2.2", + "@types/bcryptjs": "^3.0.0", "@types/qrcode": "^1.5.6", "tailwindcss": "^4.1.18", "typescript": "^5.9.2", @@ -394,6 +397,20 @@ "vite-tsconfig-paths": "^5.1.4", }, }, + "packages/domain": { + "name": "@aura/domain", + "version": "0.1.0", + "dependencies": { + "base64-js": "^1.5.1", + "crypto-js": "^4.2.0", + "fast-json-stable-stringify": "^2.1.0", + "tweetnacl": "^1.0.3", + }, + "devDependencies": { + "@types/crypto-js": "^4.2.2", + "typescript": "~5.9.3", + }, + }, "packages/eslint-config": { "name": "@aura/eslint-config", "version": "0.0.0", @@ -426,6 +443,7 @@ "name": "@aura/sdk", "version": "0.0.14-beta.1", "dependencies": { + "@aura/domain": "workspace:*", "@lit-app/state": "^1.0.0", "@lit-labs/router": "^0.1.4", "@lit-labs/signals": "^0.1.2", @@ -433,7 +451,6 @@ "@zerodev/ecdsa-validator": "^5.4.9", "@zerodev/wagmi": "^4.6.7", "axios": "^1.13.5", - "base64-js": "^1.5.1", "lit": "^3.3.0", "tweetnacl": "^1.0.3", "viem": "^2.45.3", @@ -530,6 +547,8 @@ "@aura/core-old": ["@aura/core-old@workspace:apps/core-old"], + "@aura/domain": ["@aura/domain@workspace:packages/domain"], + "@aura/eslint-config": ["@aura/eslint-config@workspace:packages/eslint-config"], "@aura/interface": ["@aura/interface@workspace:apps/interface"], @@ -2008,6 +2027,8 @@ "@tanstack/match-sorter-utils": ["@tanstack/match-sorter-utils@8.19.4", "", { "dependencies": { "remove-accents": "0.5.0" } }, "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg=="], + "@tanstack/query-async-storage-persister": ["@tanstack/query-async-storage-persister@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14", "@tanstack/query-persist-client-core": "5.100.14" } }, "sha512-otwoi4OsYoECIZBqDZ/crzyJFDXvzwCMg+T/Z+mforv9/GTqkYShddaItNC7SkFRq9VMIO3R3T71rfZfmb0NOA=="], + "@tanstack/query-core": ["@tanstack/query-core@5.99.0", "", {}, "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ=="], "@tanstack/query-devtools": ["@tanstack/query-devtools@5.99.0", "", {}, "sha512-m4ufXaJ8FjWXw7xDtyzE/6fkZAyQFg9WrbMrUpt8ZecRJx58jiFOZ2lxZMphZdIpAnIeto/S8stbwLKLusyckQ=="], @@ -2028,6 +2049,8 @@ "@tanstack/solid-query": ["@tanstack/solid-query@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-eKW5fPWuNjGBjK9To/DNNS2b3HwTvD58T6CZbN6H0HyCjDrBOlH8q4qyJQS9gR9EXflSiivgQK+DUzg3KIHNDw=="], + "@tanstack/solid-query-persist-client": ["@tanstack/solid-query-persist-client@5.100.14", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.100.14" }, "peerDependencies": { "@tanstack/solid-query": "^5.100.14", "solid-js": "^1.6.0" } }, "sha512-CdQaaOTl9MEcfG7nJ//5rfwu0dA7W5FxIpXhRjcNYR86tKRmgKkfne98HZWpw8EGdiXYstPHClw071oNrTFgPw=="], + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="], @@ -2118,6 +2141,8 @@ "@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="], + "@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="], + "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], "@types/caseless": ["@types/caseless@0.12.5", "", {}, "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg=="], @@ -5306,6 +5331,8 @@ "@aura/core/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@aura/domain/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@aura/eslint-config/eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="], "@aura/eslint-config/eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], @@ -5862,8 +5889,14 @@ "@tailwindcss/vite/tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + "@tanstack/query-async-storage-persister/@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="], + + "@tanstack/query-async-storage-persister/@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" } }, "sha512-mn60cqoQO/xB6aHxp/hxlSj5mcdcTO4tjj4SXSz5MKzkaMZnvcEGySz3+cGQOT8McREN56fL41L0eR//v5RwNw=="], + "@tanstack/solid-query/@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="], + "@tanstack/solid-query-persist-client/@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.100.14", "", { "dependencies": { "@tanstack/query-core": "5.100.14" } }, "sha512-mn60cqoQO/xB6aHxp/hxlSj5mcdcTO4tjj4SXSz5MKzkaMZnvcEGySz3+cGQOT8McREN56fL41L0eR//v5RwNw=="], + "@tanstack/vue-query/@tanstack/query-core": ["@tanstack/query-core@5.100.11", "", {}, "sha512-lmE0994apShXPj8CUxgx4ch5yUJhE9k/+tVwihBvPOyerACWdBocfFg24t8+0RhtlTd7tEgchDkhlCxNssvDxw=="], "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], @@ -7376,6 +7409,8 @@ "@tailwindcss/postcss/@tailwindcss/oxide/@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + "@tanstack/solid-query-persist-client/@tanstack/query-persist-client-core/@tanstack/query-core": ["@tanstack/query-core@5.100.14", "", {}, "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew=="], + "@toruslabs/base-controllers/@metamask/rpc-errors/@metamask/utils": ["@metamask/utils@9.3.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.1.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "debug": "^4.3.4", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-w8CVbdkDrVXFJbfBSlDfafDR6BAkpDmv1bC1UJVCoVny5tW2RKAdn9i68Xf7asYT4TnUhl/hN4zfUiKQq9II4g=="], "@toruslabs/base-controllers/@toruslabs/broadcast-channel/oblivious-set": ["oblivious-set@1.1.1", "", {}, "sha512-Oh+8fK09mgGmAshFdH6hSVco6KZmd1tTwNFWj35OvzdmJTMZtAkbn05zar2iG3v6sDs1JLEtOiBGNb6BHwkb2w=="], diff --git a/packages/domain/package.json b/packages/domain/package.json new file mode 100644 index 0000000..4a676d7 --- /dev/null +++ b/packages/domain/package.json @@ -0,0 +1,29 @@ +{ + "name": "@aura/domain", + "version": "0.1.0", + "type": "module", + "description": "Framework-agnostic Aura/BrightID domain logic: crypto, recovery channels, scoring, verifications and shared types", + "license": "MIT", + "files": ["dist", "src", "package.json"], + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "base64-js": "^1.5.1", + "crypto-js": "^4.2.0", + "fast-json-stable-stringify": "^2.1.0", + "tweetnacl": "^1.0.3" + }, + "devDependencies": { + "@types/crypto-js": "^4.2.2", + "typescript": "~5.9.3" + } +} diff --git a/apps/core/src/shared/lib/channel.ts b/packages/domain/src/channel.ts similarity index 100% rename from apps/core/src/shared/lib/channel.ts rename to packages/domain/src/channel.ts diff --git a/apps/core/src/shared/lib/crypto.ts b/packages/domain/src/crypto.ts similarity index 74% rename from apps/core/src/shared/lib/crypto.ts rename to packages/domain/src/crypto.ts index 2accf15..6fb3200 100644 --- a/apps/core/src/shared/lib/crypto.ts +++ b/packages/domain/src/crypto.ts @@ -1,30 +1,18 @@ -import { fromByteArray } from 'base64-js'; +import { fromByteArray, toByteArray } from 'base64-js'; import CryptoJS from 'crypto-js'; import nacl from 'tweetnacl'; -import type { BrightIdBackup } from '../types'; export function encryptData(data: string, password: string) { return CryptoJS.AES.encrypt(data, password).toString(); } -export function encryptUserData(userData: BrightIdBackup, password: string) { - return encryptData(JSON.stringify(userData), password); -} - export function decryptData(data: string, password: string) { return CryptoJS.AES.decrypt(data, password).toString(CryptoJS.enc.Utf8); } -export function decryptUserData( - encryptedUserData: string, - password: string, -): BrightIdBackup { - return JSON.parse(decryptData(encryptedUserData, password)); -} - const URL_SAFE_MAP: Record = { '/': '_', '+': '-', '=': '' }; export const b64ToUrlSafeB64 = (s: string) => - s.replace(/[/+=]/g, (c) => URL_SAFE_MAP[c]); + s.replace(/[/+=]/g, (c) => URL_SAFE_MAP[c] ?? c); export const hash = (data: string) => { const b = CryptoJS.SHA256(data).toString(CryptoJS.enc.Base64); @@ -41,6 +29,13 @@ export const urlSafeRandomKey = (bytes = 16): string => export const uInt8ArrayToB64 = (array: Uint8Array): string => fromByteArray(array); +/** Decode a standard (non-url-safe) base64 string into bytes. */ +export const b64ToUint8Array = (str: string): Uint8Array => toByteArray(str); + +/** UTF-8 encode a string into bytes (matches the old app's `strToUint8Array`). */ +export const strToUint8Array = (str: string): Uint8Array => + new TextEncoder().encode(str); + export const wordArrayToB64 = (wa: CryptoJS.lib.WordArray) => CryptoJS.enc.Base64.stringify(wa); diff --git a/packages/domain/src/globals.d.ts b/packages/domain/src/globals.d.ts new file mode 100644 index 0000000..ce74e25 --- /dev/null +++ b/packages/domain/src/globals.d.ts @@ -0,0 +1,4 @@ +declare module "fast-json-stable-stringify" { + /** Deterministic JSON serialization with sorted object keys. */ + export default function stringify(value: unknown): string +} diff --git a/packages/domain/src/http.ts b/packages/domain/src/http.ts new file mode 100644 index 0000000..77b4708 --- /dev/null +++ b/packages/domain/src/http.ts @@ -0,0 +1,71 @@ +/** Default per-request ceiling so a hung server can't wedge a query forever. */ +const REQUEST_TIMEOUT_MS = 30_000 + +/** Non-2xx response — carries the status so callers can branch on it. */ +export class HttpError extends Error { + readonly status: number + + constructor(message: string, status: number) { + super(message) + this.name = "HttpError" + this.status = status + } +} + +/** + * Whether an error is a 404 — e.g. a brightid profile or connections lookup + * for a user the aura node hasn't seen yet (profiles are created by their + * first received evaluation). + */ +export const isNotFound = (e: unknown): boolean => + e instanceof HttpError && e.status === 404 + +/** + * Fetch with a timeout, honoring an optional caller signal (e.g. query + * cancellation on unmount). Aborts on whichever fires first. + */ +async function fetchWithTimeout( + url: string, + init?: RequestInit, + signal?: AbortSignal, +): Promise { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS) + const composed = signal ? AbortSignal.any([signal, timeout]) : timeout + return fetch(url, { ...init, signal: composed }) +} + +/** GET + parse JSON, throwing on non-2xx. */ +export async function getJson(url: string, signal?: AbortSignal): Promise { + const res = await fetchWithTimeout(url, undefined, signal) + if (!res.ok) + throw new HttpError(`GET ${url} failed with status ${res.status}`, res.status) + return (await res.json()) as T +} + +/** GET raw text, throwing on non-2xx (encrypted backup blobs). */ +export async function getText(url: string, signal?: AbortSignal): Promise { + const res = await fetchWithTimeout(url, undefined, signal) + if (!res.ok) + throw new HttpError(`GET ${url} failed with status ${res.status}`, res.status) + return await res.text() +} + +/** POST a JSON body + parse the JSON response, throwing on non-2xx. */ +export async function postJson( + url: string, + body: unknown, + signal?: AbortSignal, +): Promise { + const res = await fetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + signal, + ) + if (!res.ok) + throw new HttpError(`POST ${url} failed with status ${res.status}`, res.status) + return (await res.json()) as T +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts new file mode 100644 index 0000000..b51b041 --- /dev/null +++ b/packages/domain/src/index.ts @@ -0,0 +1,15 @@ +export * from "./channel" +export * from "./crypto" +export * from "./http" +export * from "./labels" +export * from "./levels" +export * from "./notifications" +export * from "./operations" +export * from "./passkeys" +export * from "./recovery" +export * from "./score" +export * from "./types/aura" +export * from "./types/dashboard" +export * from "./types/evaluations" +export * from "./verifications" +export * from "./view-mode" diff --git a/packages/domain/src/labels.ts b/packages/domain/src/labels.ts new file mode 100644 index 0000000..a42c348 --- /dev/null +++ b/packages/domain/src/labels.ts @@ -0,0 +1,28 @@ +import { EvaluationCategory } from "./types/evaluations" + +/** Human label for a confidence value (1–4). */ +export const CONFIDENCE_LABELS: Record = { + 1: "Low", + 2: "Medium", + 3: "High", + 4: "Very High", +} + +export const confidenceLabel = (value: number): string => + CONFIDENCE_LABELS[Math.abs(value)] ?? String(Math.abs(value)) + +/** Display label for an evaluation category. */ +export const categoryLabel: Record = { + [EvaluationCategory.SUBJECT]: "Subject", + [EvaluationCategory.PLAYER]: "Player", + [EvaluationCategory.TRAINER]: "Trainer", + [EvaluationCategory.MANAGER]: "Manager", +} + +/** Who evaluates a category: subjects ← players ← trainers ← managers. */ +export const categoryEvaluatedBy: Record = { + [EvaluationCategory.SUBJECT]: EvaluationCategory.PLAYER, + [EvaluationCategory.PLAYER]: EvaluationCategory.TRAINER, + [EvaluationCategory.TRAINER]: EvaluationCategory.MANAGER, + [EvaluationCategory.MANAGER]: EvaluationCategory.MANAGER, +} diff --git a/apps/core/src/shared/lib/levels.ts b/packages/domain/src/levels.ts similarity index 90% rename from apps/core/src/shared/lib/levels.ts rename to packages/domain/src/levels.ts index 212eb58..fe27cfe 100644 --- a/apps/core/src/shared/lib/levels.ts +++ b/packages/domain/src/levels.ts @@ -1,4 +1,4 @@ -import { EvaluationCategory } from "@/types/evaluations" +import { EvaluationCategory } from "./types/evaluations" export const subjectLevelPoints = [ 0, 1_000_000, 5_000_000, 10_000_000, 150_000_000, diff --git a/packages/domain/src/notifications.ts b/packages/domain/src/notifications.ts new file mode 100644 index 0000000..dda4791 --- /dev/null +++ b/packages/domain/src/notifications.ts @@ -0,0 +1,234 @@ +import type { AuraNodeBrightIdConnection, Verifications } from "./types/aura" +import { EvaluationCategory, EvaluationValue } from "./types/evaluations" +import { getAuraVerification } from "./verifications" + +/** Thresholds from the old app's notifications store. */ +export const ALERT_THRESHOLDS = { + LEVEL_CHANGE: 1, + SCORE_CHANGE_PERCENT: 35, +} + +export type NotificationKind = + | "evaluation" + | "score-increase" + | "score-decrease" + | "level-increase" + | "level-decrease" + +export interface NotificationAlert { + id: string + kind: NotificationKind + category: EvaluationCategory + /** Who the alert is about — links to their subject page. */ + about: string + /** Optional second party (e.g. the subject an evaluator rated). */ + to: string | null + previous: number | null + next: number | null + timestamp: number + viewed: boolean +} + +export interface TrackedProfile { + level: number | null + score: number | null + /** evaluator → confidence; only tracked for outbound subjects. */ + evaluators?: Record +} + +/** Keyed by `${id}:${category}`. */ +export type TrackedProfiles = Record + +export const trackedKey = (id: string, category: EvaluationCategory) => + `${id}:${category}` + +export const NOTIFICATION_CATEGORIES = [ + EvaluationCategory.SUBJECT, + EvaluationCategory.PLAYER, + EvaluationCategory.TRAINER, + EvaluationCategory.MANAGER, +] + +const alertId = ( + kind: NotificationKind, + about: string, + category: EvaluationCategory, + timestamp: number, +) => `${kind}:${about}:${category}:${timestamp}` + +function levelScoreAlerts( + id: string, + category: EvaluationCategory, + prev: TrackedProfile | undefined, + level: number | null, + score: number | null, + now: number, +): NotificationAlert[] { + if (!prev) return [] + const alerts: NotificationAlert[] = [] + + if ( + level !== null && + prev.level !== null && + Math.abs(prev.level - level) >= ALERT_THRESHOLDS.LEVEL_CHANGE + ) { + alerts.push({ + id: alertId(level > prev.level ? "level-increase" : "level-decrease", id, category, now), + kind: level > prev.level ? "level-increase" : "level-decrease", + category, + about: id, + to: null, + previous: prev.level, + next: level, + timestamp: now, + viewed: false, + }) + } + + if ( + score !== null && + prev.score !== null && + prev.score !== 0 && + Math.abs((score / prev.score) * 100 - 100) >= + ALERT_THRESHOLDS.SCORE_CHANGE_PERCENT + ) { + alerts.push({ + id: alertId(score > prev.score ? "score-increase" : "score-decrease", id, category, now), + kind: score > prev.score ? "score-increase" : "score-decrease", + category, + about: id, + to: null, + previous: prev.score, + next: score, + timestamp: now, + viewed: false, + }) + } + + return alerts +} + +/** + * Pure diff of fresh node data against the previously tracked snapshot. + * Produces alerts for: own level/score changes per category, new inbound + * evaluations of the user, and changes on subjects the user has evaluated + * (their level/score plus other evaluators' new or changed ratings). + * + * The first run (`lastFetch === null`) only seeds the tracked snapshot and + * yields no alerts — otherwise every existing evaluation would fire at once. + * No I/O and no clock access: the caller passes `now`. + */ +export function diffNotifications(params: { + subjectId: string + /** The user's own verifications (from their profile). */ + verifications: Verifications | undefined + /** The user's inbound connections (evaluations of them ride along). */ + inbound: AuraNodeBrightIdConnection[] + /** The user's outbound connections (subjects they evaluated). */ + outbound: AuraNodeBrightIdConnection[] + prevTracked: TrackedProfiles + lastFetch: number | null + now: number +}): { alerts: NotificationAlert[]; tracked: TrackedProfiles } { + const { subjectId, verifications, inbound, outbound, prevTracked, lastFetch, now } = params + + const seedOnly = lastFetch === null + const tracked: TrackedProfiles = { ...prevTracked } + const alerts: NotificationAlert[] = [] + + // ── Own level/score per category ───────────────────────── + for (const category of NOTIFICATION_CATEGORIES) { + const v = getAuraVerification(verifications, category) + const key = trackedKey(subjectId, category) + if (!seedOnly) { + alerts.push( + ...levelScoreAlerts( + subjectId, + category, + tracked[key], + v?.level ?? null, + v?.score ?? null, + now, + ), + ) + } + tracked[key] = { level: v?.level ?? null, score: v?.score ?? null } + } + + // ── New inbound evaluations of the user ────────────────── + if (!seedOnly) { + for (const connection of inbound) { + for (const e of connection.auraEvaluations ?? []) { + const modified = e.modified || connection.timestamp + if (modified <= (lastFetch ?? 0)) continue + alerts.push({ + id: alertId("evaluation", connection.id, e.category, modified), + kind: "evaluation", + category: e.category, + about: connection.id, + to: subjectId, + previous: null, + next: + (e.evaluation === EvaluationValue.POSITIVE ? 1 : -1) * e.confidence, + timestamp: modified, + viewed: false, + }) + } + } + } + + // ── Subjects the user evaluated ────────────────────────── + for (const subject of outbound) { + const categories = new Set( + (subject.auraEvaluations ?? []).map((e) => e.category), + ) + for (const category of categories) { + const v = getAuraVerification(subject.verifications, category) + const key = trackedKey(subject.id, category) + const prev = tracked[key] + + const evaluators: Record = {} + for (const impact of v?.impacts ?? []) { + evaluators[impact.evaluator] = impact.confidence + } + + if (prev && !seedOnly) { + alerts.push( + ...levelScoreAlerts( + subject.id, + category, + prev, + v?.level ?? null, + v?.score ?? null, + now, + ), + ) + + // Other evaluators newly rating (or re-rating) this subject. + for (const impact of v?.impacts ?? []) { + if (impact.evaluator === subjectId) continue + if (prev.evaluators?.[impact.evaluator] === impact.confidence) continue + alerts.push({ + id: alertId("evaluation", impact.evaluator, category, impact.modified), + kind: "evaluation", + category, + about: impact.evaluator, + to: subject.id, + previous: prev.evaluators?.[impact.evaluator] ?? null, + next: impact.confidence, + timestamp: impact.modified, + viewed: false, + }) + } + } + + tracked[key] = { + level: v?.level ?? null, + score: v?.score ?? null, + evaluators, + } + } + } + + return { alerts, tracked } +} diff --git a/packages/domain/src/operations.ts b/packages/domain/src/operations.ts new file mode 100644 index 0000000..7f2b7ff --- /dev/null +++ b/packages/domain/src/operations.ts @@ -0,0 +1,143 @@ +import stringify from "fast-json-stable-stringify" +import nacl from "tweetnacl" +import { hash, strToUint8Array, uInt8ArrayToB64 } from "./crypto" +import { getJson, postJson } from "./http" +import type { + EvaluateOperation, + EvaluationCategory, + EvaluationValue, + OperationState, +} from "./types/evaluations" + +/** BrightID operation protocol version (constant in the old app). */ +const OP_VERSION = 6 + +/** + * The signed "Evaluate" operation exactly as the node canonicalizes and hashes + * it. Key set and value types must match the server's expectation byte-for-byte + * — `fast-json-stable-stringify` sorts keys, so the serialized message is + * identical regardless of insertion order here. + */ +export interface SignedEvaluateOp { + name: "Evaluate" + evaluator: string + evaluated: string + evaluation: EvaluationValue + confidence: number + domain: "BrightID" + category: EvaluationCategory + timestamp: number + v: number + sig: string +} + +export interface BuildEvaluateOperationParams { + evaluator: string + evaluated: string + evaluation: EvaluationValue + confidence: number + category: EvaluationCategory + timestamp: number + /** Ed25519 secret key bytes (caller decodes the stored b64 key). */ + secretKey: Uint8Array +} + +/** + * Build and sign an "Evaluate" operation. Pure: no I/O. Returns the signed op + * plus the canonical `message` it was signed over (also what the node hashes), + * so the caller can verify the returned hash without re-serializing. + */ +export function buildEvaluateOperation(params: BuildEvaluateOperationParams): { + op: SignedEvaluateOp + message: string +} { + const unsigned = { + name: "Evaluate" as const, + evaluator: params.evaluator, + evaluated: params.evaluated, + evaluation: params.evaluation, + confidence: params.confidence, + domain: "BrightID" as const, + category: params.category, + timestamp: params.timestamp, + v: OP_VERSION, + } + + const message = stringify(unsigned) + const sig = uInt8ArrayToB64( + nacl.sign.detached(strToUint8Array(message), params.secretKey), + ) + + return { op: { ...unsigned, sig }, message } +} + +/** + * Build, sign, and submit an "Evaluate" operation to the node, returning an + * `EvaluateOperation` the operations store can track. The node independently + * canonicalizes the op and returns its hash; we verify it equals `hash(message)` + * (SHA256 -> url-safe b64) and throw on mismatch so a rejected op never lands in + * the store as if accepted. + */ +export async function submitEvaluateOperation( + nodeUrl: string, + params: BuildEvaluateOperationParams, + signal?: AbortSignal, +): Promise { + const { op, message } = buildEvaluateOperation(params) + + const res = await postJson<{ data: { hash: string } }>( + `${nodeUrl}/operations`, + op, + signal, + ) + + const returnedHash = res.data?.hash + if (returnedHash !== hash(message)) { + throw new Error("Invalid operation hash returned from server") + } + + return { + hash: returnedHash, + evaluator: op.evaluator, + evaluated: op.evaluated, + category: op.category, + confidence: op.confidence, + evaluation: op.evaluation, + timestamp: op.timestamp, + postTimestamp: Date.now(), + state: "INIT", + } +} + +/** + * The node reports operation states in lowercase; the domain `OperationState` + * is uppercase. Map known values, defaulting unexpected strings to `"UNKNOWN"`. + */ +const NODE_STATE_TO_OPERATION_STATE: Record = { + unknown: "UNKNOWN", + init: "INIT", + sent: "SENT", + applied: "APPLIED", + failed: "FAILED", + expired: "EXPIRED", +} + +/** + * Fetch the current state of a submitted operation from the node. GETs + * `${nodeUrl}/operations/${hash}` (response shape `{ data: { state, result } }`, + * matching the old `getOperationState`) and normalizes the lowercase node state + * to the uppercase domain `OperationState`. Pure aside from the GET; unknown or + * missing states resolve to `"UNKNOWN"`. Throws only on transport errors. + */ +export async function fetchOperationState( + nodeUrl: string, + hash: string, + signal?: AbortSignal, +): Promise { + const res = await getJson<{ data?: { state?: string } }>( + `${nodeUrl}/operations/${hash}`, + signal, + ) + const nodeState = res.data?.state ?? "" + return NODE_STATE_TO_OPERATION_STATE[nodeState] ?? "UNKNOWN" +} diff --git a/packages/domain/src/passkeys.ts b/packages/domain/src/passkeys.ts new file mode 100644 index 0000000..746f06e --- /dev/null +++ b/packages/domain/src/passkeys.ts @@ -0,0 +1,176 @@ +import nacl from "tweetnacl" +import { + b64ToUint8Array, + b64ToUrlSafeB64, + strToUint8Array, + uInt8ArrayToB64, +} from "./crypto" + +/** + * Passkey-derived identity. The Ed25519 keypair is derived deterministically + * from the passkey's PRF output, so the same passkey always yields the same + * identity — no server-side account storage is needed. + */ +export interface PasskeyIdentity { + /** Url-safe b64 of the public key — used as the user's id on the node. */ + id: string + /** Standard b64 (same shape as `generateB64Keypair`). */ + publicKey: string + privateKey: string +} + +interface PRFExtensionResult { + prf?: { results?: { first?: ArrayBuffer } } +} + +const LS_CRED_ID = "aura_passkey_cred_id" +const LS_PUB_KEY = "aura_passkey_pub_key" + +// Derivation constants — must never change, or existing passkeys would map to +// different identities. Shared with the interface app's passkey login. +const PRF_SALT = "BrightID" +const HKDF_INFO = "BrightID Ed25519 Identity v1" + +const PRF_UNSUPPORTED_MESSAGE = + "This device's passkeys don't support key derivation (PRF). " + + "Try Chrome on desktop, or Safari 17.5+ on iOS." + +async function hkdf(inputKeyMaterial: Uint8Array): Promise { + const baseKey = await crypto.subtle.importKey( + "raw", + inputKeyMaterial as BufferSource, + { name: "HKDF" }, + false, + ["deriveBits"], + ) + const derived = await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: strToUint8Array(PRF_SALT) as BufferSource, + info: strToUint8Array(HKDF_INFO) as BufferSource, + }, + baseKey, + 32 * 8, + ) + return new Uint8Array(derived) +} + +function identityFromSeed(seed: Uint8Array): PasskeyIdentity { + const keypair = nacl.sign.keyPair.fromSeed(seed) + // nacl copies the seed; zero our copy so it doesn't linger in memory + seed.fill(0) + const publicKey = uInt8ArrayToB64(keypair.publicKey) + return { + id: b64ToUrlSafeB64(publicKey), + publicKey, + privateKey: uInt8ArrayToB64(keypair.secretKey), + } +} + +function rememberCredential(rawId: ArrayBuffer, publicKey: string): void { + localStorage.setItem(LS_CRED_ID, uInt8ArrayToB64(new Uint8Array(rawId))) + localStorage.setItem(LS_PUB_KEY, publicKey) +} + +/** Whether a passkey was already registered from this device/browser. */ +export function hasPasskeyCredential(): boolean { + return !!localStorage.getItem(LS_CRED_ID) +} + +/** Forget the locally remembered credential (does not delete the passkey). */ +export function clearPasskeyCredential(): void { + localStorage.removeItem(LS_CRED_ID) + localStorage.removeItem(LS_PUB_KEY) +} + +/** + * Create a brand-new passkey and derive an identity from it. + * + * Some authenticators return the PRF output during creation itself (one tap); + * others only evaluate PRF during authentication, so we fall back to an + * immediate assertion against the freshly created credential. + */ +export async function createPasskeyIdentity( + username: string, +): Promise { + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rp: { name: "Aura" }, + user: { + id: crypto.getRandomValues(new Uint8Array(16)), + name: username, + displayName: username, + }, + pubKeyCredParams: [ + { type: "public-key", alg: -7 }, // ES256 + { type: "public-key", alg: -257 }, // RS256 + ], + authenticatorSelection: { + userVerification: "required", + residentKey: "required", + }, + extensions: { + prf: { eval: { first: strToUint8Array(PRF_SALT) } }, + } as AuthenticationExtensionsClientInputs, + }, + })) as PublicKeyCredential | null + + if (!credential) throw new Error("Passkey creation was cancelled") + + // Persist the credential id so the fallback assertion targets it + localStorage.setItem( + LS_CRED_ID, + uInt8ArrayToB64(new Uint8Array(credential.rawId)), + ) + + const extensions = credential.getClientExtensionResults() as PRFExtensionResult + const prfFromCreate = extensions.prf?.results?.first + if (!prfFromCreate) return getPasskeyIdentity() + + const identity = identityFromSeed(await hkdf(new Uint8Array(prfFromCreate))) + rememberCredential(credential.rawId, identity.publicKey) + return identity +} + +/** + * Re-derive the identity from an existing passkey (one tap). Works without a + * locally remembered credential too — the browser then offers any resident + * passkey for this origin, which lets a user sign in on a new browser profile. + */ +export async function getPasskeyIdentity(): Promise { + const savedId = localStorage.getItem(LS_CRED_ID) + const savedPubKey = localStorage.getItem(LS_PUB_KEY) + + const assertion = (await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + allowCredentials: savedId + ? [{ id: b64ToUint8Array(savedId) as BufferSource, type: "public-key" }] + : [], + userVerification: "required", + extensions: { + prf: { eval: { first: strToUint8Array(PRF_SALT) } }, + } as AuthenticationExtensionsClientInputs, + }, + })) as PublicKeyCredential | null + + if (!assertion) throw new Error("Passkey prompt was cancelled") + + const extensions = assertion.getClientExtensionResults() as PRFExtensionResult + const prfBytes = extensions.prf?.results?.first + if (!prfBytes) throw new Error(PRF_UNSUPPORTED_MESSAGE) + + const identity = identityFromSeed(await hkdf(new Uint8Array(prfBytes))) + + if (savedPubKey && savedPubKey !== identity.publicKey) { + throw new Error( + "Wrong passkey selected — this would produce a different identity. " + + "Please use the passkey you originally registered with.", + ) + } + + rememberCredential(assertion.rawId, identity.publicKey) + return identity +} diff --git a/apps/core/src/shared/lib/recovery.ts b/packages/domain/src/recovery.ts similarity index 95% rename from apps/core/src/shared/lib/recovery.ts rename to packages/domain/src/recovery.ts index bee4769..5d8f7cd 100644 --- a/apps/core/src/shared/lib/recovery.ts +++ b/packages/domain/src/recovery.ts @@ -2,8 +2,8 @@ import { downloadFromChannel, listChannel, uploadToChannel, -} from "@/shared/lib/channel" -import { b64ToUrlSafeB64, decryptData, hash } from "@/shared/lib/crypto" +} from "./channel" +import { b64ToUrlSafeB64, decryptData, hash } from "./crypto" export const RECOVERY_CHANNEL_TTL = 24 * 60 * 60 * 1000 // 1 day export const CHANNEL_POLL_INTERVAL = 3000 diff --git a/packages/domain/src/score.ts b/packages/domain/src/score.ts new file mode 100644 index 0000000..b1d6dd4 --- /dev/null +++ b/packages/domain/src/score.ts @@ -0,0 +1,75 @@ +import { userLevelPoints } from "./levels" +import type { EvaluationCategory } from "./types/evaluations" +import type { AuraImpactRaw } from "./types/aura" + +export const calculateImpact = (score: number, rating: number) => + rating > 0 ? score * rating : rating * score * 4 + +export const calculateImpactPercent = ( + impacts: AuraImpactRaw[], + score: number, +) => { + const sumImpacts = impacts.reduce((p, c) => Math.abs(c.impact) + p, 0) + return sumImpacts ? score / sumImpacts : 0 +} + +export const calculateRemainingScoreToNextLevel = ( + view: EvaluationCategory, + score: number, +) => { + const levels = userLevelPoints[view] + const nextLevelStart = levels.find((item) => item > score) ?? levels.at(-1)! + return nextLevelStart - score +} + +export const maximumScoreTobeReached = 4_000_000_000 + +export const progressSections = [ + 0.00000875, 0.000125, 0.025, 0.075, 0.2, 0.375, 0.625, 1, +] + +/** 0–100 progress of a score across the non-linear level sections. */ +export const calculateUserScorePercentage = ( + _view: EvaluationCategory, + score: number, +) => { + if (score < 0) return -1 + if (score === 0) return 0 + if (score > maximumScoreTobeReached) return 100 + + const sectionsPassed = progressSections.filter( + (pct) => pct * maximumScoreTobeReached < score, + ) + const currentSection = + (progressSections[sectionsPassed.length] ?? 1) * maximumScoreTobeReached + + return ( + (sectionsPassed.length / progressSections.length) * 100 + + (score / currentSection) * progressSections.length + ) +} + +/** Level index for a category given its impact ratings. */ +export const calculateSubjectScore = ( + category: EvaluationCategory, + ratings: AuraImpactRaw[], +) => { + const levels = userLevelPoints[category] + const score = ratings.reduce((p, item) => (item.score ?? 0) + p, 0) + return levels.findIndex((item) => item > score) +} + +/** + * One evaluator's share of a subject's total absolute impact, as a rounded + * percentage — null when the evaluator has no impact or there is none at all. + */ +export const impactShare = ( + impacts: AuraImpactRaw[] | null | undefined, + evaluator: string | undefined, +): number | null => { + if (!impacts?.length || !evaluator) return null + const total = impacts.reduce((sum, i) => sum + Math.abs(i.impact), 0) + const mine = impacts.find((i) => i.evaluator === evaluator)?.impact + if (!mine || !total) return null + return Math.round((Math.abs(mine) / total) * 100) +} diff --git a/packages/domain/src/types/aura.ts b/packages/domain/src/types/aura.ts new file mode 100644 index 0000000..37d91fa --- /dev/null +++ b/packages/domain/src/types/aura.ts @@ -0,0 +1,116 @@ +import type { EvaluationCategory, EvaluationValue } from "./evaluations" + +/** BrightID verification / domain types (aura node `?withVerifications=true`). */ +export interface AuraImpactRaw { + evaluator: string + level?: number | null + score: number | null + confidence: number + impact: number + modified: number +} + +export type Domain = { + name: "BrightID" + categories: { + name: EvaluationCategory + score: number + level: number + impacts: AuraImpactRaw[] + }[] +} + +export type Verifications = { + name: string + block: number + timestamp: number + domains?: Domain[] +}[] + +export interface BrightIdProfile { + createdAt: number + verifications: Verifications +} + +/** Connections + evaluations from the aura node. */ +export type ConnectionLevel = + | "reported" + | "suspicious" + | "just met" + | "already known" + | "recovery" + | "aura only" + +export type BrightIdConnection = { + id: string + level: ConnectionLevel + reportReason: string | null + timestamp: number +} + +export type AuraEvaluation = { + evaluation: EvaluationValue + confidence: number + domain: "BrightID" + category: EvaluationCategory + modified: number + timestamp: number +} + +export type AuraNodeBrightIdConnection = BrightIdConnection & { + auraEvaluations?: AuraEvaluation[] + verifications: Verifications +} + +export type AuraNodeConnectionsResponse = { + data: { + connections: AuraNodeBrightIdConnection[] + } +} + +/** A rating derived from a connection's aura evaluations (or a pending op). */ +export type AuraRating = { + id?: number + toBrightId: string + fromBrightId: string + rating: string + timestamp: number + createdAt: string + updatedAt: string + category: EvaluationCategory + isPending: boolean + verifications?: Verifications +} + +/** Connections as stored in the (decrypted) BrightID backup. */ +export type BrightIdBackupConnection = BrightIdConnection & + Partial<{ + name: string + connectionDate: number + photo: { filename: string } + status: "verified" + notificationToken: string + socialMedia: unknown[] + verifications: Verifications + incomingLevel: ConnectionLevel + }> + +export type AuraNodeBrightIdConnectionWithBackupData = + AuraNodeBrightIdConnection & BrightIdBackupConnection + +export type BrightIdBackup = { + userData: { + id: string + name: string + photo: { filename: string } + } + connections: BrightIdBackupConnection[] + groups: unknown[] +} + +export type BrightIdBackupWithAuraConnectionData = Omit< + BrightIdBackup, + "connections" +> & { + connections: AuraNodeBrightIdConnectionWithBackupData[] +} diff --git a/packages/domain/src/types/dashboard.ts b/packages/domain/src/types/dashboard.ts new file mode 100644 index 0000000..d3c120b --- /dev/null +++ b/packages/domain/src/types/dashboard.ts @@ -0,0 +1,10 @@ +/** Which role lens the user is viewing the app through. */ +export enum PreferredView { + PLAYER, + TRAINER, + MANAGER_EVALUATING_TRAINER, + MANAGER_EVALUATING_MANAGER, +} + + + diff --git a/apps/core/src/types/evaluations.ts b/packages/domain/src/types/evaluations.ts similarity index 100% rename from apps/core/src/types/evaluations.ts rename to packages/domain/src/types/evaluations.ts diff --git a/packages/domain/src/verifications.ts b/packages/domain/src/verifications.ts new file mode 100644 index 0000000..bde48a3 --- /dev/null +++ b/packages/domain/src/verifications.ts @@ -0,0 +1,34 @@ +import type { EvaluationCategory } from "./types/evaluations" +import type { Verifications } from "./types/aura" + +export const getUserHasRecovery = (verifications?: Verifications) => + verifications + ? !!verifications.find((v) => v.name === "SocialRecoverySetup") + : null + +/** The Aura → BrightID domain category entry for a given evaluation category. */ +export const getAuraVerification = ( + verifications: Verifications | undefined, + category: EvaluationCategory, +) => { + if (!verifications) return null + return verifications + .find((v) => v.name === "Aura") + ?.domains?.find((d) => d.name === "BrightID") + ?.categories.find((c) => c.name === category) +} + +/** Extract level/score for a subject in a category from their verifications. */ +export function parseVerifications( + verifications: Verifications | undefined, + category: EvaluationCategory, +) { + const auraVerification = getAuraVerification(verifications, category) + return { + userHasRecovery: getUserHasRecovery(verifications), + auraVerification, + auraLevel: auraVerification?.level ?? null, + auraScore: auraVerification?.score ?? null, + auraImpacts: auraVerification?.impacts ?? null, + } +} diff --git a/packages/domain/src/view-mode.ts b/packages/domain/src/view-mode.ts new file mode 100644 index 0000000..efa617b --- /dev/null +++ b/packages/domain/src/view-mode.ts @@ -0,0 +1,57 @@ +import { EvaluationCategory } from "./types/evaluations" +import { PreferredView } from "./types/dashboard" + +/** The role we *evaluate* when in a given view. */ +export const viewModeToViewAs: Record = { + [PreferredView.PLAYER]: EvaluationCategory.SUBJECT, + [PreferredView.TRAINER]: EvaluationCategory.PLAYER, + [PreferredView.MANAGER_EVALUATING_TRAINER]: EvaluationCategory.TRAINER, + [PreferredView.MANAGER_EVALUATING_MANAGER]: EvaluationCategory.MANAGER, +} + +/** The view that evaluates the current view's role (one level up). */ +export const viewModeToEvaluatorViewMode: Record = + { + [PreferredView.PLAYER]: PreferredView.TRAINER, + [PreferredView.TRAINER]: PreferredView.MANAGER_EVALUATING_TRAINER, + [PreferredView.MANAGER_EVALUATING_TRAINER]: + PreferredView.MANAGER_EVALUATING_MANAGER, + [PreferredView.MANAGER_EVALUATING_MANAGER]: + PreferredView.MANAGER_EVALUATING_MANAGER, + } + +/** `?viewas=` query value → view. */ +export const viewAsToViewMode: Record = { + [EvaluationCategory.SUBJECT]: PreferredView.PLAYER, + [EvaluationCategory.PLAYER]: PreferredView.TRAINER, + [EvaluationCategory.TRAINER]: PreferredView.MANAGER_EVALUATING_TRAINER, + [EvaluationCategory.MANAGER]: PreferredView.MANAGER_EVALUATING_MANAGER, +} + +export const viewModeToString: Record = { + [PreferredView.PLAYER]: "Player", + [PreferredView.TRAINER]: "Trainer", + [PreferredView.MANAGER_EVALUATING_TRAINER]: "Manager", + [PreferredView.MANAGER_EVALUATING_MANAGER]: "Manager", +} + +/** URL slug (`/home/:view`) ↔ view. Manager collapses to one slug. */ +export const viewSlugToViewMode: Record = { + player: PreferredView.PLAYER, + trainer: PreferredView.TRAINER, + manager: PreferredView.MANAGER_EVALUATING_TRAINER, +} + +export const viewModeToSlug: Record = { + [PreferredView.PLAYER]: "player", + [PreferredView.TRAINER]: "trainer", + [PreferredView.MANAGER_EVALUATING_TRAINER]: "manager", + [PreferredView.MANAGER_EVALUATING_MANAGER]: "manager", +} + +export const viewModeSubjectString: Record = { + [PreferredView.PLAYER]: "Subject", + [PreferredView.TRAINER]: "Player", + [PreferredView.MANAGER_EVALUATING_TRAINER]: "Trainer", + [PreferredView.MANAGER_EVALUATING_MANAGER]: "Manager", +} diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json new file mode 100644 index 0000000..7dfa1c4 --- /dev/null +++ b/packages/domain/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../typescript-config/base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 377b8c6..e9349db 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -55,6 +55,7 @@ } }, "dependencies": { + "@aura/domain": "workspace:*", "@lit-app/state": "^1.0.0", "@lit-labs/router": "^0.1.4", "@lit-labs/signals": "^0.1.2", @@ -62,7 +63,6 @@ "@zerodev/ecdsa-validator": "^5.4.9", "@zerodev/wagmi": "^4.6.7", "axios": "^1.13.5", - "base64-js": "^1.5.1", "lit": "^3.3.0", "tweetnacl": "^1.0.3", "viem": "^2.45.3", diff --git a/packages/sdk/src/utils/crypto.ts b/packages/sdk/src/utils/crypto.ts index aed036e..9806fe9 100644 --- a/packages/sdk/src/utils/crypto.ts +++ b/packages/sdk/src/utils/crypto.ts @@ -1,55 +1,24 @@ -import { fromByteArray } from "base64-js" -import CryptoJS from "crypto-js" -import nacl from "tweetnacl" import { type BrightIdBackup } from "types" - -export function encryptData(data: string, password: string) { - return CryptoJS.AES.encrypt(data, password).toString() -} +import { decryptData, encryptData } from "@aura/domain/crypto" + +// Shared crypto primitives live in @aura/domain — re-exported here to keep the +// sdk's public API unchanged. +export { + b64ToUrlSafeB64, + decryptData, + encryptData, + generateB64Keypair, + hash, + randomWordArray, + uInt8ArrayToB64, + urlSafeRandomKey, + wordArrayToB64, +} from "@aura/domain/crypto" export function encryptUserData(userData: BrightIdBackup, password: string) { return encryptData(JSON.stringify(userData), password) } -export function decryptData(data: string, password: string) { - return CryptoJS.AES.decrypt(data, password).toString(CryptoJS.enc.Utf8) -} - export function decryptUserData(encryptedUserData: string, password: string) { return JSON.parse(decryptData(encryptedUserData, password)) } - -export const b64ToUrlSafeB64 = (s: string) => { - const alts: { - [key: string]: string - } = { - "/": "_", - "+": "-", - "=": "", - } - return s.replace(/[/+=]/g, (c) => alts[c]!) -} - -export const hash = (data: string) => { - const h = CryptoJS.SHA256(data) - const b = h.toString(CryptoJS.enc.Base64) - return b64ToUrlSafeB64(b) -} - -export const randomWordArray = (size: number) => - CryptoJS.lib.WordArray.random(size) - -export const wordArrayToB64 = (WordArray: CryptoJS.lib.WordArray) => - CryptoJS.enc.Base64.stringify(WordArray) - -export const generateB64Keypair = () => { - const { publicKey, secretKey } = nacl.sign.keyPair() - const b64PublicKey = fromByteArray(publicKey) - - const b64SecretKey = fromByteArray(secretKey) - - return { - privateKey: b64SecretKey, - publicKey: b64PublicKey, - } -} diff --git a/packages/ui/src/components/button.ts b/packages/ui/src/components/button.ts index e332e31..fcd2e41 100644 --- a/packages/ui/src/components/button.ts +++ b/packages/ui/src/components/button.ts @@ -1,7 +1,7 @@ import { css, html, LitElement } from "lit" import { customElement, property } from "lit/decorators.js" -export type ButtonVariant = "default" | "secondary" | "ghost" | "outline" +export type ButtonVariant = "default" | "secondary" | "ghost" | "outline" | "glass" export type ButtonSize = "sm" | "md" | "lg" | "icon" | "icon-sm" | "icon-lg" export type ButtonColors = | "primary" @@ -27,6 +27,13 @@ export class ButtonElement extends LitElement { @property({ type: Boolean, reflect: true }) disabled: boolean = false + /** + * Toggle/pill state: a selected button renders filled in its palette color + * regardless of variant, so call sites don't juggle variant/color pairs. + */ + @property({ type: Boolean, reflect: true }) + selected: boolean = false + @property({}) class: string | undefined @@ -199,11 +206,49 @@ export class ButtonElement extends LitElement { --bg: color-mix(in oklch, var(--color) 10%, transparent); } - /* Optional: stronger hover for outline */ - /* :host([variant="outline"]) button:hover:not(:disabled) { - --bg: color-mix(in oklch, var(--color) 15%, transparent); - --border: oklch(from var(--color) calc(l - 0.05) c h); - } */ + /* Glass — frosted translucent surface, mirrors a-card[variant="glass"] */ + :host([variant="glass"]) button { + --bg: transparent; + /* blend toward the theme foreground so any palette stays readable */ + --fg: color-mix(in oklch, var(--color) 45%, var(--foreground)); + --border: color-mix(in oklch, var(--color) 20%, transparent); + background: linear-gradient( + 135deg, + color-mix(in oklch, var(--color) 18%, transparent) 0%, + color-mix(in oklch, var(--color) 8%, transparent) 100% + ); + backdrop-filter: blur(6px) saturate(180%); + -webkit-backdrop-filter: blur(6px) saturate(180%); + box-shadow: + 0 1px 2px oklch(0 0 0 / 0.05), + 0 8px 24px oklch(0 0 0 / 0.1); + } + + :host([variant="glass"]) button:hover:not(:disabled) { + background: linear-gradient( + 135deg, + color-mix(in oklch, var(--color) 28%, transparent) 0%, + color-mix(in oklch, var(--color) 14%, transparent) 100% + ); + box-shadow: + 0 2px 4px oklch(0 0 0 / 0.06), + 0 12px 32px oklch(0 0 0 / 0.16); + } + + /* Selected (toggle) — filled, wins over any variant. Uses the primary + palette so a neutral pill group lights up consistently when chosen. */ + :host([selected]) button { + --bg: var(--primary); + --fg: var(--primary-foreground); + --border: transparent; + background: var(--primary); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + :host([selected]) button:hover:not(:disabled) { + background: oklch(from var(--primary) calc(l + 0.05) c h); + } ` protected render() { diff --git a/packages/ui/src/components/card.ts b/packages/ui/src/components/card.ts index b8bef1b..a5df58e 100644 --- a/packages/ui/src/components/card.ts +++ b/packages/ui/src/components/card.ts @@ -3,8 +3,13 @@ import { customElement, property } from "lit/decorators.js" @customElement("a-card") export class CardElement extends LitElement { + /** Glass is the design default; use `variant="default"` for a solid card. */ @property({ reflect: true }) - variant: "default" | "glass" = "default" + variant: "default" | "glass" = "glass" + + /** Clickable card: pointer cursor, hover lift, press feedback. */ + @property({ type: Boolean, reflect: true }) + interactive: boolean = false static styles: CSSResultGroup = css` :host { @@ -48,6 +53,38 @@ export class CardElement extends LitElement { 0 2px 4px oklch(0 0 0 / 0.06), 0 16px 50px oklch(0 0 0 / 0.18); } + + /* Interactive cards behave like buttons */ + :host([interactive]) { + cursor: pointer; + transition: + background 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.15s ease; + -webkit-tap-highlight-color: transparent; + } + + :host([interactive][variant="default"]:hover) { + background: color-mix(in oklch, var(--foreground) 6%, var(--card)); + } + + :host([interactive][variant="glass"]:hover) { + background: linear-gradient( + 135deg, + color-mix(in oklch, var(--card) 30%, transparent) 0%, + color-mix(in oklch, var(--card) 14%, transparent) 100% + ); + } + + :host([interactive]:active) { + transform: scale(0.99); + } + + :host([interactive]:focus-visible) { + outline: 2px solid var(--ring); + outline-offset: 2px; + } ` render() { diff --git a/packages/ui/src/components/head.ts b/packages/ui/src/components/head.ts index a286f77..9cade9b 100644 --- a/packages/ui/src/components/head.ts +++ b/packages/ui/src/components/head.ts @@ -7,12 +7,12 @@ export class HeadingElement extends LitElement { level: "1" | "2" | "3" | "4" | "5" | "6" = "2" static styles = css` + /* No default margins — pages lay headings out with flex/gap; opt into + spacing with utility classes instead of fighting baked-in margins. */ :host { display: block; color: var(--foreground); font-weight: 600; - margin-top: 1.5em; - margin-bottom: 0.5em; } :host([level="1"]) { diff --git a/packages/widgets/src/verification/index.ts b/packages/widgets/src/verification/index.ts index 72805e3..762782b 100644 --- a/packages/widgets/src/verification/index.ts +++ b/packages/widgets/src/verification/index.ts @@ -5,7 +5,7 @@ import { focusedProject } from '@/lib/projects' import { projects } from '@/states/projects' import { userBrightId } from '@/states/user' import type { Project } from '@/types/projects' -import { getProjects } from '@/utils/apis' +import { getProjects, verifyProject, type VerificationSignature } from '@/utils/apis' import { EvaluationCategory } from '@/utils/aura' import { getLevelupProgress } from '@/utils/score' import { getSubjectVerifications } from '@/utils/subject' @@ -51,6 +51,7 @@ export class AppVerificationElement extends SignalWatcher(LitElement) { @state() private step: Step = 'connect' @state() private previousStep: Step = 'connect' @state() private isLoadingVerification = false + @state() private isGeneratingSignature = false @state() private verificationData: ProgressStepData | null = null static styles: CSSResultGroup = css` @@ -208,11 +209,40 @@ export class AppVerificationElement extends SignalWatcher(LitElement) { this._goToStep('connect') } - private _handleContinue() { + private async _handleContinue() { + const brightId = userBrightId.get() + const data = this.verificationData + let signature: VerificationSignature | undefined + + // Only verified users reach the success step, so generate the signature + // from the API before handing control back to the embedding app. + if (brightId) { + this.isGeneratingSignature = true + try { + const result = await verifyProject(this.projectId, { + userId: brightId, + client: focusedProject.get()?.name ?? 'aura-get-verified', + auraScore: data?.auraScore, + auraLevel: data?.auraLevel + }) + signature = result?.signature + } catch (err) { + console.error('Failed to generate verification signature', err) + } finally { + this.isGeneratingSignature = false + } + } + window.parent.postMessage( JSON.stringify({ type: 'verification-success', - app: 'aura-get-verified' + app: 'aura-get-verified', + data: { + brightId, + signature, + auraLevel: data?.auraLevel, + auraScore: data?.auraScore + } }), '*' ) @@ -273,6 +303,7 @@ export class AppVerificationElement extends SignalWatcher(LitElement) { this._handleContinue()} > ` diff --git a/packages/widgets/src/verification/success-step.ts b/packages/widgets/src/verification/success-step.ts index 2367e82..8be0110 100644 --- a/packages/widgets/src/verification/success-step.ts +++ b/packages/widgets/src/verification/success-step.ts @@ -6,6 +6,7 @@ import './level-badge' export class VerificationSuccessElement extends LitElement { @property() appName = '' @property({ type: Number }) level = 1 + @property({ type: Boolean }) loading = false static styles: CSSResultGroup = css` :host { @@ -131,8 +132,12 @@ export class VerificationSuccessElement extends LitElement {
- this._emit('continue')}> - Continue to ${this.appName} + this._emit('continue')} + > + ${this.loading ? 'Generating signature…' : html`Continue to ${this.appName}`}

This verification can be used across multiple apps

From 53290f5d5f349ede23c6066644c8a73b0459c8b7 Mon Sep 17 00:00:00 2001 From: Bob six Date: Fri, 3 Jul 2026 15:13:40 +0330 Subject: [PATCH 4/6] fix: added zoom controls and improved the modals for evaluations --- apps/core/PORTING_PLAN.md | 33 +- .../components/charts/evaluations-chart.tsx | 204 +++++++---- .../src/components/charts/zoom-controls.tsx | 67 ++++ .../evaluation/credibility-details.tsx | 17 +- .../components/evaluation/evaluation-card.tsx | 57 +++- .../evaluation/op-notifications.tsx | 15 + apps/core/src/components/home/home-header.tsx | 20 +- .../src/components/list/incremental-list.tsx | 56 ++++ .../src/components/search/global-search.tsx | 144 ++++++++ .../core/src/components/shared/app-header.tsx | 46 +++ .../components/shared/notification-bell.tsx | 19 ++ .../subject/subject-profile-card.tsx | 4 +- apps/core/src/hooks/use-backup.ts | 34 +- apps/core/src/hooks/use-evaluate-subject.ts | 11 +- .../hooks/use-subject-inbound-evaluations.ts | 4 + apps/core/src/hooks/use-subjects-list.ts | 27 +- apps/core/src/hooks/use-view-mode.ts | 16 +- apps/core/src/routes/[...404].tsx | 1 - apps/core/src/routes/_layout.tsx | 2 + apps/core/src/routes/contact-info.tsx | 14 +- apps/core/src/routes/home/[view]/index.tsx | 32 +- apps/core/src/routes/index.tsx | 2 +- apps/core/src/routes/notifications.tsx | 70 ++-- .../core/src/routes/subject/[id]/[viewas].tsx | 316 ++++++++++++++++++ apps/core/src/routes/subject/[id]/index.tsx | 306 +---------------- packages/ui/src/components/tab.ts | 34 +- 26 files changed, 1032 insertions(+), 519 deletions(-) create mode 100644 apps/core/src/components/charts/zoom-controls.tsx create mode 100644 apps/core/src/components/list/incremental-list.tsx create mode 100644 apps/core/src/components/search/global-search.tsx create mode 100644 apps/core/src/components/shared/app-header.tsx create mode 100644 apps/core/src/components/shared/notification-bell.tsx create mode 100644 apps/core/src/routes/subject/[id]/[viewas].tsx diff --git a/apps/core/PORTING_PLAN.md b/apps/core/PORTING_PLAN.md index 616d9c1..10f80da 100644 --- a/apps/core/PORTING_PLAN.md +++ b/apps/core/PORTING_PLAN.md @@ -36,12 +36,33 @@ unread-badge bell in home header) · T4.2 `/dashboard` · T4.3 `/domain-overview (static stats, as planned). **Also done:** T2.5 evaluations chart — lightweight CSS-bars -(`components/charts/evaluations-chart.tsx`, no chart lib; drag-zoom dropped), -slotted into the subject Overview tab + credibility-details dialog, plus a -mini impact strip on `subject-card`. - -**Remaining:** M5 shared building blocks (profile pictures, list -states/infinite scroll, global search) — pull in on demand. +(`components/charts/evaluations-chart.tsx`, no chart lib), slotted into the +subject Overview tab + credibility-details dialog, plus a mini impact strip on +`subject-card`. Old drag-zoom replaced by explicit window-based zoom/pan +controls (`components/charts/zoom-controls.tsx`); strip no longer scrolls (bars +flex to fit, capped max-width). + +**Also done:** T5.1 profile pictures (`components/home/avatar.tsx` — real +backup photo with initials fallback + hover preview) · T5.2 list states + +infinite scroll (`components/list/list-state.tsx` + `incremental-list.tsx`, +IntersectionObserver-paced ``; adopted in home evaluate list, subject +evaluations/connections/activity tabs, notifications, contact-info). + +**Also done:** T5.3 global search (`components/search/global-search.tsx` — +dialog with live connection results; row click → `/subject/:id`, Enter → +`/home/player?search=`; home-list search now lives in the `?search=` URL param +so deep links pre-filter) · persistent app header ported from the old +`DefaultHeader` (`components/shared/app-header.tsx` in the root layout: home / +search / bell / settings on all signed-in pages; home keeps its own header, +now with search + settings too; bell extracted to +`components/shared/notification-bell.tsx`) · subject page honors an injected +`?name=` for ids the backup can't resolve. + +**Remaining (untracked follow-ups, all optional):** PWA service-worker +update flow + push (settings version card is static) · i18n (old i18next — +copy is English-only, likely fine to drop) · IndexedDB-blocked fallback screen +· subject chart-view / connections help modals (only evidence-help ported) · +param-less `/subject` self-profile fallback · `?gravatar=` injected avatar. **Also done:** T2.6 credibility-details modal (collapsed to per-role stat rows, no chart/tabs). diff --git a/apps/core/src/components/charts/evaluations-chart.tsx b/apps/core/src/components/charts/evaluations-chart.tsx index af4f602..f8e4791 100644 --- a/apps/core/src/components/charts/evaluations-chart.tsx +++ b/apps/core/src/components/charts/evaluations-chart.tsx @@ -1,5 +1,6 @@ -import { createMemo, For, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, Show } from "solid-js" import { barColor } from "@/components/charts/colors" +import ZoomControls from "@/components/charts/zoom-controls" import Avatar from "@/components/home/avatar" import { useNameResolver } from "@/hooks/use-backup" import { authStore } from "@/store/auth" @@ -7,6 +8,7 @@ import type { AuraImpactRaw } from "@aura/domain/types/aura" // Old chart showed evaluator pictures for up to 20 bars, 16–40px by count. const MAX_AVATARS = 20 + const avatarSize = (count: number) => Math.round(40 - ((40 - 16) / (MAX_AVATARS - 1)) * (count - 1)) @@ -15,8 +17,6 @@ interface Bar { name: string /** Share of the subject's total absolute impact, signed (-100..100). */ percent: number - /** Bar height inside its half of the plot, normalized to the max (0..100). */ - height: number color: string } @@ -25,8 +25,13 @@ interface Bar { * plain CSS bars: one bar per evaluator, height = share of the subject's * total impact, negatives below the baseline. Bars use the old confidence * palettes (purple = you, orange = the focused subject) and carry the - * evaluator's picture underneath when the list is small enough. Click opens - * the evaluator. The old drag-zoom is dropped — the strip scrolls instead. + * evaluator's picture underneath when the visible window is small enough. + * Click opens the evaluator. + * + * The strip never scrolls — bars flex to fill the width and shrink as small as + * needed, capped at a max width so a handful of bars don't sprawl. To inspect + * a crowded chart, the old drag-zoom is replaced by explicit zoom/pan controls + * that slide a window over the bars (see {@link ZoomControls}). */ export default function EvaluationsChart(props: { impacts: () => AuraImpactRaw[] | null @@ -36,18 +41,16 @@ export default function EvaluationsChart(props: { }) { const nameOf = useNameResolver() - const bars = createMemo(() => { + const allBars = createMemo(() => { const impacts = (props.impacts() ?? []).filter((i) => i.impact !== 0) const total = impacts.reduce((sum, i) => sum + Math.abs(i.impact), 0) if (!total) return [] - const maxAbs = Math.max(...impacts.map((i) => Math.abs(i.impact))) return [...impacts] .sort((a, b) => a.impact - b.impact) .map((i) => ({ id: i.evaluator, name: nameOf(i.evaluator), percent: (i.impact / total) * 100, - height: Math.max(8, Math.round((Math.abs(i.impact) / maxAbs) * 100)), color: barColor( i.confidence * Math.sign(i.impact), i.evaluator, @@ -57,72 +60,151 @@ export default function EvaluationsChart(props: { })) }) + // Visible window [start, end] into allBars(). Reset to the full range + // whenever the data changes. + const [start, setStart] = createSignal(0) + const [end, setEnd] = createSignal(0) + createEffect(() => { + setStart(0) + setEnd(Math.max(0, allBars().length - 1)) + }) + + const count = () => allBars().length + const bars = createMemo(() => allBars().slice(start(), end() + 1)) + + // Heights are normalized to the tallest bar *in the window*, so zooming into + // a flat region still spreads the bars across the full plot height. + const windowMaxAbs = createMemo(() => + Math.max(1, ...bars().map((b) => Math.abs(b.percent))), + ) + const heightOf = (percent: number) => + Math.max(8, Math.round((Math.abs(percent) / windowMaxAbs()) * 100)) + const showAvatars = () => bars().length <= MAX_AVATARS + // Only reserve plot halves that have bars — an all-positive window would + // otherwise leave an empty bottom half between the bars and the avatars. + const hasPositive = createMemo(() => bars().some((b) => b.percent > 0)) + const hasNegative = createMemo(() => bars().some((b) => b.percent < 0)) + + const isFull = () => start() === 0 && end() === count() - 1 + + const zoom = (dir: 1 | -1) => { + const s = start() + const e = end() + const win = e - s + const step = Math.max(1, Math.round(win * 0.15)) + if (dir === 1) { + // zoom in — shrink the window toward its center + if (win < 2) return + const mid = (s + e) / 2 + setStart(Math.min(s + step, Math.floor(mid))) + setEnd(Math.max(e - step, Math.ceil(mid))) + } else { + setStart(Math.max(0, s - step)) + setEnd(Math.min(count() - 1, e + step)) + } + } + + const pan = (dir: 1 | -1) => { + const win = end() - start() + if (dir === -1) { + const ns = Math.max(0, start() - Math.max(1, Math.round(win * 0.5))) + setStart(ns) + setEnd(ns + win) + } else { + const ne = Math.min(count() - 1, end() + Math.max(1, Math.round(win * 0.5))) + setEnd(ne) + setStart(ne - win) + } + } return ( 0} + when={count() > 0} fallback={
No evaluation impacts yet.
} > -
- - {(bar) => ( - - )} - + + + + + )} + +
) diff --git a/apps/core/src/components/charts/zoom-controls.tsx b/apps/core/src/components/charts/zoom-controls.tsx new file mode 100644 index 0000000..7ad8dc0 --- /dev/null +++ b/apps/core/src/components/charts/zoom-controls.tsx @@ -0,0 +1,67 @@ +/** + * Zoom/pan controls for the evaluations chart — ported from the old aura + * `ZoomControls`. The chart shows a sliding window over the bars; these + * buttons narrow it (zoom in), widen it (zoom out), slide it (pan) or restore + * the full range (reset). + */ +export default function ZoomControls(props: { + onReset: () => void + onZoomIn: () => void + onZoomOut: () => void + onPanLeft: () => void + onPanRight: () => void + disabledZoomIn: boolean + disabledZoomOut: boolean + disabledPanLeft: boolean + disabledPanRight: boolean +}) { + return ( +
+ props.onReset()} + > + + + props.onZoomIn()} + > + + + props.onZoomOut()} + > + + + props.onPanLeft()} + > + + + props.onPanRight()} + > + + +
+ ) +} diff --git a/apps/core/src/components/evaluation/credibility-details.tsx b/apps/core/src/components/evaluation/credibility-details.tsx index 732d473..114d1bc 100644 --- a/apps/core/src/components/evaluation/credibility-details.tsx +++ b/apps/core/src/components/evaluation/credibility-details.tsx @@ -1,4 +1,4 @@ -import { A, useNavigate } from "@solidjs/router" +import { useNavigate } from "@solidjs/router" import { createEffect, createMemo, createSignal, For, Show } from "solid-js" import type { DialogElement } from "@aura/ui" import EvaluationsChart from "@/components/charts/evaluations-chart" @@ -173,15 +173,16 @@ export default function CredibilityDetails(props: {
- props.onClose()} + data-testid="credibility-view-profile" + onClick={() => goTo(id())} > - - View profile - - + View profile +
diff --git a/apps/core/src/components/evaluation/evaluation-card.tsx b/apps/core/src/components/evaluation/evaluation-card.tsx index dd40159..a07829c 100644 --- a/apps/core/src/components/evaluation/evaluation-card.tsx +++ b/apps/core/src/components/evaluation/evaluation-card.tsx @@ -1,10 +1,15 @@ -import { Show } from "solid-js" +import { createMemo, For, Show } from "solid-js" +import { barColor } from "@/components/charts/colors" import Avatar from "@/components/home/avatar" -import { compactFormat } from "@/shared/lib/number" +import LevelScore from "@/components/shared/level-score" import { formatDuration } from "@/shared/lib/time" import type { InboundEvaluation } from "@/hooks/use-subject-inbound-evaluations" +import { authStore } from "@/store/auth" import { confidenceLabel } from "@aura/domain/labels" +/** Old card's per-evaluator graph showed the evaluator's top inbound impacts. */ +const MAX_IMPACT_BARS = 8 + /** * Inbound-evaluation row: evaluator (photo, name, level), signed rating chip * with confidence, impact share and age. @@ -16,6 +21,25 @@ export default function EvaluationCard(props: { const e = () => props.evaluation const positive = () => e().rating > 0 + /** Top evaluations of the evaluator by |impact|, normalized for the strip. */ + const impactBars = createMemo(() => { + const impacts = (e().impacts ?? []) + .filter((i) => i.impact !== 0) + .sort((a, b) => Math.abs(b.impact) - Math.abs(a.impact)) + .slice(0, MAX_IMPACT_BARS) + const max = Math.max(...impacts.map((i) => Math.abs(i.impact)), 1) + return impacts.map((i) => ({ + // Same graded palette as the big chart: red/green by signed confidence, + // purple for the logged-in user's own bar. + color: barColor( + i.confidence * Math.sign(i.impact), + i.evaluator, + authStore.user?.brightId, + ), + height: Math.max(15, Math.round((Math.abs(i.impact) / max) * 100)), + })) + }) + return (

{e().name}

-

- - Level {e().level} - · - - - - {compactFormat(e().score!)} - - · - + +

{formatDuration(e().timestamp)}

@@ -61,6 +76,22 @@ export default function EvaluationCard(props: { {e().impactPercent}% of impact + 0}> +
+ + {(bar) => ( + + )} + +
+
) diff --git a/apps/core/src/components/evaluation/op-notifications.tsx b/apps/core/src/components/evaluation/op-notifications.tsx index cdc75b8..ed1c209 100644 --- a/apps/core/src/components/evaluation/op-notifications.tsx +++ b/apps/core/src/components/evaluation/op-notifications.tsx @@ -1,6 +1,7 @@ import { fetchOperationState } from "@aura/domain/operations" import type { OperationState } from "@aura/domain/types/evaluations" import { toast } from "@aura/ui" +import { useQueryClient } from "@tanstack/solid-query" import { createEffect, createMemo, onCleanup } from "solid-js" import { useBackup } from "@/hooks/use-backup" import { NODE_API_BASE } from "@/shared/lib/api" @@ -30,6 +31,7 @@ const PENDING_STATES: ReadonlySet = new Set([ */ export default function OpNotifications() { const backup = useBackup() + const queryClient = useQueryClient() // Resolve a subject's display name from the backup, falling back to a short id. const subjectName = (id: string): string => { @@ -66,6 +68,19 @@ export default function OpNotifications() { setOperationState(hash, next) + // The op only lands on the node's reads once it APPLIES — the + // submit-time invalidation in `createEvaluateMutation` refetched while + // the node still held the old data. Re-invalidate now so the fresh + // rating/impacts replace the optimistic overlay. + if (next === "APPLIED") { + queryClient.invalidateQueries({ + queryKey: ["brightid-profile", op.evaluated], + }) + queryClient.invalidateQueries({ + queryKey: ["connections", "outbound", op.evaluator], + }) + } + if (notified.has(hash)) continue if (next === "APPLIED") { notified.add(hash) diff --git a/apps/core/src/components/home/home-header.tsx b/apps/core/src/components/home/home-header.tsx index 90efac2..4206643 100644 --- a/apps/core/src/components/home/home-header.tsx +++ b/apps/core/src/components/home/home-header.tsx @@ -1,8 +1,9 @@ import { A } from "@solidjs/router" import { For, Show } from "solid-js" +import GlobalSearch from "@/components/search/global-search" +import NotificationBell from "@/components/shared/notification-bell" import { useLevelupProgress } from "@/hooks/use-levelup-progress" import { useViewMode } from "@/hooks/use-view-mode" -import { unreadCount } from "@/store/notifications" import { viewModeToSlug, viewModeToString } from "@aura/domain/view-mode" import { PreferredView } from "@aura/domain/types/dashboard" import { EvaluationCategory } from "@aura/domain/types/evaluations" @@ -37,19 +38,12 @@ export default function HomeHeader() {
Home
- - - + + + + + - 0}> - - {unreadCount()} - - {({ view, gate }) => ( diff --git a/apps/core/src/components/list/incremental-list.tsx b/apps/core/src/components/list/incremental-list.tsx new file mode 100644 index 0000000..d88a14b --- /dev/null +++ b/apps/core/src/components/list/incremental-list.tsx @@ -0,0 +1,56 @@ +import { createEffect, createSignal, For, type JSX, onCleanup } from "solid-js" + +const DEFAULT_PAGE_SIZE = 20 + +/** + * ``-based incremental list (the old `InfiniteScrollLocal`, Solid-native): + * renders a growing window over `items` and reveals the next `pageSize` batch + * when a bottom sentinel scrolls into view. All data is already in memory — + * this only paces rendering so a long list doesn't mount hundreds of rows at + * once. Reconciliation is by item reference, like Solid's ``. + */ +export default function IncrementalList(props: { + items: T[] + children: (item: T, index: () => number) => JSX.Element + /** Rows revealed per batch (default 20). */ + pageSize?: number + /** Class for the wrapping container (e.g. `flex flex-col gap-3`). */ + class?: string +}) { + const pageSize = () => props.pageSize ?? DEFAULT_PAGE_SIZE + const [limit, setLimit] = createSignal(pageSize()) + const [sentinel, setSentinel] = createSignal() + + // Collapse the window back to one page when the list itself changes + // (filter / sort / navigation), so we don't keep a stale large limit. + createEffect(() => { + props.items + setLimit(pageSize()) + }) + + const visible = () => props.items.slice(0, limit()) + const hasMore = () => limit() < props.items.length + + // Re-observe whenever the window grows: IntersectionObserver fires once on + // observe, so reconnecting after each batch keeps filling until the sentinel + // leaves the viewport (handles tall screens / small pages without scrolling). + createEffect(() => { + const el = sentinel() + limit() + if (!el || !hasMore()) return + const observer = new IntersectionObserver((entries) => { + if (entries.some((e) => e.isIntersecting)) { + setLimit((n) => Math.min(n + pageSize(), props.items.length)) + } + }) + observer.observe(el) + onCleanup(() => observer.disconnect()) + }) + + return ( +
+ {props.children} + + ) +} diff --git a/apps/core/src/components/search/global-search.tsx b/apps/core/src/components/search/global-search.tsx new file mode 100644 index 0000000..d8289d4 --- /dev/null +++ b/apps/core/src/components/search/global-search.tsx @@ -0,0 +1,144 @@ +import { useNavigate } from "@solidjs/router" +import { createMemo, createSignal, For, Show } from "solid-js" +import Avatar from "@/components/home/avatar" +import { useBackup, useNameResolver } from "@/hooks/use-backup" +import { useMyEvaluations } from "@/hooks/use-my-evaluations" +import type { DialogElement } from "@aura/ui" + +const MAX_RESULTS = 8 + +/** + * Global search — trigger button + dialog. Ported from the old + * `GlobalSearchModal`, upgraded from a bare "jump to home with ?search=" to + * live results: matching connections open `/subject/:id` directly; Enter + * falls through to the filtered home list (old behavior). + */ +export default function GlobalSearch() { + let dialog: DialogElement | undefined + const navigate = useNavigate() + + const [query, setQuery] = createSignal("") + + // Same source as the home list: backup connections when decrypted, + // node connections as the passkey-session fallback. + const backup = useBackup() + const { connections: nodeConnections } = useMyEvaluations() + const nameOf = useNameResolver() + + const candidates = createMemo(() => { + const conns = backup.data?.connections?.length + ? backup.data.connections + : (nodeConnections() ?? []) + return [...new Map(conns.map((c) => [c.id, c])).values()].map((c) => ({ + id: c.id, + name: nameOf(c.id), + })) + }) + + const results = createMemo(() => { + const q = query().trim().toLowerCase() + if (!q) return [] + return candidates() + .filter( + (c) => + c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q), + ) + .slice(0, MAX_RESULTS) + }) + + const open = () => { + setQuery("") + dialog?.show() + } + + const goToSubject = (id: string) => { + dialog?.hide() + navigate(`/subject/${id}`) + } + + // Enter: single hit opens the subject, otherwise show the filtered list. + const submit = () => { + const q = query().trim() + if (!q) return + const hits = results() + if (hits.length === 1) return goToSubject(hits[0].id) + dialog?.hide() + navigate(`/home/player?search=${encodeURIComponent(q)}`) + } + + return ( + <> + + + + + +
+
+ + Global Search + + Search from your connections +
+ +
+ ) => setQuery(e.detail)} + onKeyDown={(e: KeyboardEvent) => e.key === "Enter" && submit()} + > + + + + Search + +
+ + 0}> +
+ + {(subject) => ( + + )} + +
+
+ + No connection matches. + +
+
+ + ) +} diff --git a/apps/core/src/components/shared/app-header.tsx b/apps/core/src/components/shared/app-header.tsx new file mode 100644 index 0000000..e2a90f6 --- /dev/null +++ b/apps/core/src/components/shared/app-header.tsx @@ -0,0 +1,46 @@ +import { A, useLocation } from "@solidjs/router" +import { Show } from "solid-js" +import GlobalSearch from "@/components/search/global-search" +import NotificationBell from "@/components/shared/notification-bell" +import { authStore } from "@/store/auth" + +/** + * Persistent app header, ported from the old `DefaultHeader`: home link on the + * left, global search / notifications bell / settings on the right. Rendered + * from the root layout on every signed-in page except the landing flow + * (`/`, `/login`, `/onboarding`) and home, which has its own richer header. + */ +export default function AppHeader() { + const location = useLocation() + + const hidden = () => { + const path = location.pathname + return ( + path === "/" || + path.startsWith("/login") || + path.startsWith("/onboarding") || + path.startsWith("/home") + ) + } + + return ( + +
+ + + + + +
+ + + + + + + +
+
+
+ ) +} diff --git a/apps/core/src/components/shared/notification-bell.tsx b/apps/core/src/components/shared/notification-bell.tsx new file mode 100644 index 0000000..85ca617 --- /dev/null +++ b/apps/core/src/components/shared/notification-bell.tsx @@ -0,0 +1,19 @@ +import { A } from "@solidjs/router" +import { Show } from "solid-js" +import { unreadCount } from "@/store/notifications" + +/** Bell link to `/notifications` with the unread-count badge. */ +export default function NotificationBell() { + return ( + + + + + 0}> + + {unreadCount()} + + + + ) +} diff --git a/apps/core/src/components/subject/subject-profile-card.tsx b/apps/core/src/components/subject/subject-profile-card.tsx index 731fc39..c2fdf50 100644 --- a/apps/core/src/components/subject/subject-profile-card.tsx +++ b/apps/core/src/components/subject/subject-profile-card.tsx @@ -11,8 +11,10 @@ import { confidenceLabel } from "@aura/domain/labels" export default function SubjectProfileCard(props: { subjectId: () => string onEvaluate: () => void + /** Display name for ids the backup can't resolve (e.g. from `?name=`). */ + fallbackName?: () => string | undefined }) { - const name = useSubjectName(props.subjectId) + const name = useSubjectName(props.subjectId, props.fallbackName) const vm = useViewMode() const v = useSubjectVerifications(props.subjectId, vm.currentEvaluationCategory) const my = useMyRating(props.subjectId, vm.currentEvaluationCategory) diff --git a/apps/core/src/hooks/use-backup.ts b/apps/core/src/hooks/use-backup.ts index fd0251b..2094642 100644 --- a/apps/core/src/hooks/use-backup.ts +++ b/apps/core/src/hooks/use-backup.ts @@ -2,7 +2,6 @@ import { createMemo } from "solid-js" import { createProfileDataQuery } from "@/queries/backup" import { authStore } from "@/store/auth" -/** The decrypted BrightID backup for the logged-in user. */ export function useBackup() { return createProfileDataQuery( () => authStore.user?.brightId ?? "", @@ -10,26 +9,33 @@ export function useBackup() { ) } -/** - * Resolve display names from the backup (self or a connection); falls back to - * a short id. Use this when a component renders many subjects — it shares one - * backup query across all lookups. - */ -export function useNameResolver() { +/** Backup name lookup; `undefined` when the backup doesn't know the id. */ +export function useNameLookup() { const backup = useBackup() - return (id: string): string => { + return (id: string): string | undefined => { const data = backup.data - if (!data) return id.slice(0, 7) + if (!data) return undefined const info = id === data.userData.id ? data.userData : data.connections.find((c) => c.id === id) - return info?.name ?? id.slice(0, 7) + return info?.name } } -/** Reactive display name for a single subject. */ -export function useSubjectName(subjectId: () => string) { - const nameOf = useNameResolver() - return createMemo(() => nameOf(subjectId())) +export function useNameResolver() { + const lookup = useNameLookup() + return (id: string): string => lookup(id) ?? id.slice(0, 7) +} + +/** `fallback` fills in for ids the backup can't resolve (e.g. `?name=`). */ +export function useSubjectName( + subjectId: () => string, + fallback?: () => string | undefined, +) { + const lookup = useNameLookup() + return createMemo(() => { + const id = subjectId() + return lookup(id) ?? fallback?.() ?? id.slice(0, 7) + }) } diff --git a/apps/core/src/hooks/use-evaluate-subject.ts b/apps/core/src/hooks/use-evaluate-subject.ts index ce9acd5..c44c47f 100644 --- a/apps/core/src/hooks/use-evaluate-subject.ts +++ b/apps/core/src/hooks/use-evaluate-subject.ts @@ -1,15 +1,8 @@ -import { EvaluationValue } from "@aura/domain/types/evaluations" import type { EvaluationCategory } from "@aura/domain/types/evaluations" -import { createEvaluateMutation } from "@/queries/evaluations" +import { EvaluationValue } from "@aura/domain/types/evaluations" import { useViewMode } from "@/hooks/use-view-mode" +import { createEvaluateMutation } from "@/queries/evaluations" -/** - * Submit an evaluation for a subject. - * - * `newRating` is a signed magnitude: a negative value is a NEGATIVE evaluation, - * positive is POSITIVE, and the confidence is its absolute value. Category - * defaults to the current view mode's category, with an optional override. - */ export function useEvaluateSubject(category?: () => EvaluationCategory) { const { currentEvaluationCategory } = useViewMode() const mutation = createEvaluateMutation() diff --git a/apps/core/src/hooks/use-subject-inbound-evaluations.ts b/apps/core/src/hooks/use-subject-inbound-evaluations.ts index b040a28..064d2ae 100644 --- a/apps/core/src/hooks/use-subject-inbound-evaluations.ts +++ b/apps/core/src/hooks/use-subject-inbound-evaluations.ts @@ -32,6 +32,9 @@ export interface InboundEvaluation { score: number | null /** This evaluation's share of the subject's total impact (percent). */ impactPercent: number | null + /** The evaluator's own inbound impacts in the relevant role, for the mini + * chart — mirrors the old card's per-evaluator graph. */ + impacts: AuraImpactRaw[] | null } /** Flatten one connection's evaluations (in a category) into rows. */ @@ -61,6 +64,7 @@ function toRows( level: standing?.level ?? null, score: standing?.score ?? null, impactPercent: impactShare(options.impacts, connection.id), + impacts: standing?.impacts ?? null, })) } diff --git a/apps/core/src/hooks/use-subjects-list.ts b/apps/core/src/hooks/use-subjects-list.ts index f9cbfc5..545bec6 100644 --- a/apps/core/src/hooks/use-subjects-list.ts +++ b/apps/core/src/hooks/use-subjects-list.ts @@ -1,3 +1,4 @@ +import { useSearchParams } from "@solidjs/router" import { createMemo, createSignal } from "solid-js" import { useBackup } from "@/hooks/use-backup" import { useMyEvaluationData } from "@/hooks/use-my-evaluations" @@ -30,9 +31,29 @@ const LEVELS: ConnectionLevel[] = [ */ export function useSubjectsList() { const backup = useBackup() - const { myRatings } = useMyEvaluationData(() => EvaluationCategory.SUBJECT) + const { myRatings, connections: nodeConnections } = useMyEvaluationData( + () => EvaluationCategory.SUBJECT, + ) + + // Connections to evaluate come from the decrypted recovery backup when we + // have one. Passkey sessions have no backup (no password to decrypt with), + // so fall back to the node's own connection list — it carries id/level/ + // timestamp; names just resolve to short ids without backup data. + const baseConnections = createMemo(() => { + const fromBackup = backup.data?.connections + if (fromBackup?.length) return fromBackup + return nodeConnections() ?? null + }) + + // Search lives in `?search=` so global search / empty-state links can deep + // link into the filtered list (old `/home?search=` behavior). + const [params, setParams] = useSearchParams() + const search = createMemo(() => + typeof params.search === "string" ? params.search : "", + ) + const setSearch = (value: string) => + setParams({ search: value || undefined }, { replace: true }) - const [search, setSearch] = createSignal("") const [sort, setSort] = createSignal("recency") const [levels, setLevels] = createSignal([]) const [ratedState, setRatedState] = createSignal("all") @@ -53,7 +74,7 @@ export function useSubjectsList() { } const subjects = createMemo(() => { - const conns = backup.data?.connections + const conns = baseConnections() const ratings = myRatings() if (!conns || ratings === null) return null diff --git a/apps/core/src/hooks/use-view-mode.ts b/apps/core/src/hooks/use-view-mode.ts index 02e9694..acbfaf0 100644 --- a/apps/core/src/hooks/use-view-mode.ts +++ b/apps/core/src/hooks/use-view-mode.ts @@ -1,5 +1,5 @@ -import { useParams, useSearchParams } from "@solidjs/router" -import { createMemo } from "solid-js" +import { PreferredView } from "@aura/domain/types/dashboard" +import { EvaluationCategory } from "@aura/domain/types/evaluations" import { viewAsToViewMode, viewModeSubjectString, @@ -7,15 +7,9 @@ import { viewModeToViewAs, viewSlugToViewMode, } from "@aura/domain/view-mode" -import { PreferredView } from "@aura/domain/types/dashboard" -import { EvaluationCategory } from "@aura/domain/types/evaluations" +import { useParams, useSearchParams } from "@solidjs/router" +import { createMemo } from "solid-js" -/** - * Reactive view-mode state. Resolution order: - * 1. `/home/:view` route slug (player|trainer|manager) - * 2. `?viewas=` search param - * 3. default (Player) - */ export function useViewMode() { const routeParams = useParams() const [params, setParams] = useSearchParams() @@ -24,7 +18,7 @@ export function useViewMode() { const slug = routeParams.view if (slug && slug in viewSlugToViewMode) return viewSlugToViewMode[slug] - const viewAs = params.viewas + const viewAs = routeParams.viewas ?? params.viewas if ( typeof viewAs === "string" && (Object.values(EvaluationCategory) as string[]).includes(viewAs) diff --git a/apps/core/src/routes/[...404].tsx b/apps/core/src/routes/[...404].tsx index b354b3d..9bac0a3 100644 --- a/apps/core/src/routes/[...404].tsx +++ b/apps/core/src/routes/[...404].tsx @@ -1,7 +1,6 @@ import { A } from "@solidjs/router" import FadeIn from "@/components/motions/fade-in" -// `[...404].tsx` => `*` catch-all. export default function NotFound() { return (
diff --git a/apps/core/src/routes/_layout.tsx b/apps/core/src/routes/_layout.tsx index ac14814..fa3eb35 100644 --- a/apps/core/src/routes/_layout.tsx +++ b/apps/core/src/routes/_layout.tsx @@ -1,4 +1,5 @@ import type { ParentComponent } from "solid-js" +import AppHeader from "@/components/shared/app-header" const Layout: ParentComponent = (props) => { return ( @@ -12,6 +13,7 @@ const Layout: ParentComponent = (props) => {
+ {props.children}
) diff --git a/apps/core/src/routes/contact-info.tsx b/apps/core/src/routes/contact-info.tsx index 36a6de4..4e75c95 100644 --- a/apps/core/src/routes/contact-info.tsx +++ b/apps/core/src/routes/contact-info.tsx @@ -1,6 +1,7 @@ import { createSignal, For, Show } from "solid-js" import { toast } from "@aura/ui" import type { DialogElement } from "@aura/ui" +import ListState from "@/components/list/list-state" import { useRequireSession } from "@/hooks/use-require-session" import { createStoreContactMutation } from "@/queries/contacts" import { @@ -55,13 +56,10 @@ export default function ContactInfoPage() {
- 0} - fallback={ -
- No contact info added yet. -
- } + {(contact) => ( @@ -74,7 +72,7 @@ export default function ContactInfoPage() { )} -
+
diff --git a/apps/core/src/routes/home/[view]/index.tsx b/apps/core/src/routes/home/[view]/index.tsx index b481bf4..a9e721a 100644 --- a/apps/core/src/routes/home/[view]/index.tsx +++ b/apps/core/src/routes/home/[view]/index.tsx @@ -1,7 +1,9 @@ -import { createSignal, For, Show } from "solid-js" +import { createSignal } from "solid-js" import EvaluateModal from "@/components/evaluation/evaluate-modal" import SubjectCard from "@/components/home/subject-card" import SubjectListControls from "@/components/home/subject-list-controls" +import IncrementalList from "@/components/list/incremental-list" +import ListState from "@/components/list/list-state" import { useSubjectsList } from "@/hooks/use-subjects-list" export default function HomeEvaluate() { @@ -15,25 +17,17 @@ export default function HomeEvaluate() { controls={controls} count={subjects()?.length ?? 0} /> - Loading…
} + - 0} - fallback={ -
- No connections to evaluate yet. -
- } - > - - {(connection) => ( - - )} - -
- + + {(connection) => ( + + )} + +
setEvaluating(null)} />
diff --git a/apps/core/src/routes/index.tsx b/apps/core/src/routes/index.tsx index 3e50457..7d72aab 100644 --- a/apps/core/src/routes/index.tsx +++ b/apps/core/src/routes/index.tsx @@ -1,6 +1,6 @@ +import { A } from "@solidjs/router" import FadeIn from "@/components/motions/fade-in" import Scale from "@/components/motions/scale" -import { A } from "@solidjs/router" const Splash = () => { return ( diff --git a/apps/core/src/routes/notifications.tsx b/apps/core/src/routes/notifications.tsx index 2759086..95cae18 100644 --- a/apps/core/src/routes/notifications.tsx +++ b/apps/core/src/routes/notifications.tsx @@ -1,5 +1,7 @@ import { useNavigate } from "@solidjs/router" import { createMemo, createSignal, For, Show } from "solid-js" +import IncrementalList from "@/components/list/incremental-list" +import ListState from "@/components/list/list-state" import { useNameResolver } from "@/hooks/use-backup" import { useRequireSession } from "@/hooks/use-require-session" import { @@ -109,44 +111,38 @@ export default function NotificationsPage() { - 0} - fallback={ -
- Nothing here yet — new evaluations and level or score changes will - show up here. -
- } + -
- - {(alert) => ( - open(alert)} - > - -
-

- {title(alert)} -

-

- {alert.category} ·{" "} - {formatDuration(alert.timestamp)} -

-
- - - -
- )} -
-
-
+ + {(alert) => ( + open(alert)} + > + +
+

+ {title(alert)} +

+

+ {alert.category} ·{" "} + {formatDuration(alert.timestamp)} +

+
+ + + +
+ )} +
+ ) } diff --git a/apps/core/src/routes/subject/[id]/[viewas].tsx b/apps/core/src/routes/subject/[id]/[viewas].tsx new file mode 100644 index 0000000..aa31cf3 --- /dev/null +++ b/apps/core/src/routes/subject/[id]/[viewas].tsx @@ -0,0 +1,316 @@ +import { A, useNavigate, useParams, useSearchParams } from "@solidjs/router" +import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" +import EvaluationsChart from "@/components/charts/evaluations-chart" +import CredibilityDetails from "@/components/evaluation/credibility-details" +import EvaluateModal from "@/components/evaluation/evaluate-modal" +import EvaluationCard from "@/components/evaluation/evaluation-card" +import ProfileNotFoundHint from "@/components/home/profile-not-found-hint" +import IncrementalList from "@/components/list/incremental-list" +import ListState from "@/components/list/list-state" +import ConnectionCard from "@/components/subject/connection-card" +import EvidenceHelp from "@/components/subject/evidence-help" +import SubjectProfileCard from "@/components/subject/subject-profile-card" +import { useRequireSession } from "@/hooks/use-require-session" +import { + useSubjectInboundConnections, + useSubjectInboundEvaluations, + useSubjectOutboundEvaluations, +} from "@/hooks/use-subject-inbound-evaluations" +import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { useViewMode } from "@/hooks/use-view-mode" +import { authStore } from "@/store/auth" +import { isNotFound } from "@aura/domain/http" +import { categoryLabel } from "@aura/domain/labels" +import { PreferredView } from "@aura/domain/types/dashboard" +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +type ProfileTab = "overview" | "evaluations" | "connections" | "activity" + +/** Which category an "Activity" tab shows per view (one role below). */ +const ACTIVITY_CATEGORY: Partial> = { + [PreferredView.TRAINER]: EvaluationCategory.SUBJECT, + [PreferredView.MANAGER_EVALUATING_TRAINER]: EvaluationCategory.PLAYER, + [PreferredView.MANAGER_EVALUATING_MANAGER]: EvaluationCategory.TRAINER, +} + +/** + * Subject profile — ported from the old app's `_app.subject.$id`: a single + * page with a view-as switcher, profile info (incl. your own evaluation) and + * the Evidence tabs (Overview / Connections-or-Activity / Evaluations). + * Dropped from the old page: the player-history breadcrumb sequence and the + * manager-on-manager extra activity tab. + */ +export default function SubjectPage() { + const params = useParams() + useRequireSession() + const navigate = useNavigate() + const [query, setQuery] = useSearchParams() + + const subjectId = () => params.id ?? "" + const vm = useViewMode() + const category = vm.currentEvaluationCategory + const v = useSubjectVerifications(subjectId, category) + + const [evaluating, setEvaluating] = createSignal(null) + const [detailsId, setDetailsId] = createSignal(null) + const [search, setSearch] = createSignal("") + + // ── Tabs (tracked in ?tab=, like the old app) ─────────── + const isPlayerView = () => vm.currentViewMode() === PreferredView.PLAYER + const tab = createMemo(() => { + const t = query.tab + if (t === "evaluations" || t === "overview") return t + if (t === "connections" && isPlayerView()) return t + if (t === "activity" && !isPlayerView()) return t + return "overview" + }) + const setTab = (t: ProfileTab) => { + setSearch("") + setQuery({ tab: t }) + } + // Leaving the view that owns the current tab resets to overview. + createEffect(() => { + if (query.tab === "connections" && !isPlayerView()) setTab("overview") + if (query.tab === "activity" && isPlayerView()) setTab("overview") + }) + + // ── Data ──────────────────────────────────────────────── + const inbound = useSubjectInboundEvaluations(subjectId, category) + const connections = useSubjectInboundConnections(subjectId) + const activity = useSubjectOutboundEvaluations( + subjectId, + () => ACTIVITY_CATEGORY[vm.currentViewMode()] ?? EvaluationCategory.SUBJECT, + ) + + // View-as options: Subject always; a role once the subject has activity in + // it (same gating as the old header's authorized tabs). + const activityIn = { + [EvaluationCategory.PLAYER]: useSubjectOutboundEvaluations(subjectId, () => EvaluationCategory.PLAYER), + [EvaluationCategory.TRAINER]: useSubjectOutboundEvaluations(subjectId, () => EvaluationCategory.TRAINER), + [EvaluationCategory.MANAGER]: useSubjectOutboundEvaluations(subjectId, () => EvaluationCategory.MANAGER), + } + const authorizedViewAs = createMemo(() => + Object.values(EvaluationCategory).filter( + (c) => + c === EvaluationCategory.SUBJECT || + (activityIn[c as keyof typeof activityIn].evaluations()?.length ?? 0) > 0, + ), + ) + + const profileMissing = () => isNotFound(v.query.error) + const isSelf = () => subjectId() === authStore.user?.brightId + + // ── Search (shared signal, cleared on tab switch) ─────── + const matches = (id: string, name: string) => { + const q = search().trim().toLowerCase() + return !q || name.toLowerCase().includes(q) || id.toLowerCase().includes(q) + } + const filteredEvaluations = createMemo( + () => inbound.evaluations()?.filter((e) => matches(e.evaluatorId, e.name)) ?? [], + ) + const filteredActivity = createMemo( + () => activity.evaluations()?.filter((e) => matches(e.evaluatorId, e.name)) ?? [], + ) + const filteredConnections = createMemo( + () => + connections + .connections() + ?.filter((c) => matches(c.id, connections.nameOf(c.id))) ?? [], + ) + + // Component (not a shared element) — each panel needs its own DOM node. + const SearchInput = () => ( + ) => setSearch(e.detail)} + > + + + ) + + return ( +
+ {/* ── Header: back link + view-as switcher (each role is a route) ── */} +
+ + ← Back + +
+ + {(c) => ( + + navigate( + `/subject/${subjectId()}/${c}${ + query.tab ? `?tab=${query.tab}` : "" + }`, + ) + } + > + {categoryLabel[c]} + + )} + +
+
+ + setEvaluating(subjectId())} + fallbackName={() => + typeof query.name === "string" ? query.name : undefined + } + /> + + + + + + {/* ── Evidence tabs ── */} + + ) => + setTab(e.detail.value as ProfileTab) + } + > + Overview + Activity} + > + Connections + + Evaluations + + +
+ +
+ + {inbound.positiveCount() ?? "-"} + + Positive +
+
+ + {inbound.negativeCount() ?? "-"} + + Negative +
+
+ + {inbound.evaluations()?.length ?? "-"} + + Total +
+
+ + +

+ Evaluation impacts — tap a bar for details +

+ v.auraImpacts()} + onBarClick={setDetailsId} + /> +
+ + setTab("evaluations")} + > + View evaluations + +
+
+ + +
+ + + + {(evaluation) => ( + + )} + + +
+
+ + + + +
+ + + + {(connection) => ( + + )} + + +
+
+
+ + +
+ + + + {(evaluation) => ( + + )} + + +
+
+
+
+
+ + setEvaluating(null)} /> + setDetailsId(null)} /> +
+ ) +} diff --git a/apps/core/src/routes/subject/[id]/index.tsx b/apps/core/src/routes/subject/[id]/index.tsx index a7b659a..6e1e534 100644 --- a/apps/core/src/routes/subject/[id]/index.tsx +++ b/apps/core/src/routes/subject/[id]/index.tsx @@ -1,304 +1,2 @@ -import { A, useParams, useSearchParams } from "@solidjs/router" -import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" -import EvaluationsChart from "@/components/charts/evaluations-chart" -import CredibilityDetails from "@/components/evaluation/credibility-details" -import EvaluateModal from "@/components/evaluation/evaluate-modal" -import EvaluationCard from "@/components/evaluation/evaluation-card" -import LevelProgress from "@/components/home/level-progress" -import ProfileNotFoundHint from "@/components/home/profile-not-found-hint" -import ListState from "@/components/list/list-state" -import ConnectionCard from "@/components/subject/connection-card" -import EvidenceHelp from "@/components/subject/evidence-help" -import SubjectProfileCard from "@/components/subject/subject-profile-card" -import { useRequireSession } from "@/hooks/use-require-session" -import { - useSubjectInboundConnections, - useSubjectInboundEvaluations, - useSubjectOutboundEvaluations, -} from "@/hooks/use-subject-inbound-evaluations" -import { useSubjectVerifications } from "@/hooks/use-subject-verifications" -import { useViewMode } from "@/hooks/use-view-mode" -import { authStore } from "@/store/auth" -import { isNotFound } from "@aura/domain/http" -import { categoryLabel } from "@aura/domain/labels" -import { PreferredView } from "@aura/domain/types/dashboard" -import { EvaluationCategory } from "@aura/domain/types/evaluations" - -type ProfileTab = "overview" | "evaluations" | "connections" | "activity" - -/** Which category an "Activity" tab shows per view (one role below). */ -const ACTIVITY_CATEGORY: Partial> = { - [PreferredView.TRAINER]: EvaluationCategory.SUBJECT, - [PreferredView.MANAGER_EVALUATING_TRAINER]: EvaluationCategory.PLAYER, - [PreferredView.MANAGER_EVALUATING_MANAGER]: EvaluationCategory.TRAINER, -} - -/** - * Subject profile — ported from the old app's `_app.subject.$id`: a single - * page with a view-as switcher, profile info (incl. your own evaluation) and - * the Evidence tabs (Overview / Connections-or-Activity / Evaluations). - * Dropped from the old page: the player-history breadcrumb sequence and the - * manager-on-manager extra activity tab. - */ -export default function SubjectPage() { - const params = useParams() - useRequireSession() - const [query, setQuery] = useSearchParams() - - const subjectId = () => params.id ?? "" - const vm = useViewMode() - const category = vm.currentEvaluationCategory - const v = useSubjectVerifications(subjectId, category) - - const [evaluating, setEvaluating] = createSignal(null) - const [detailsId, setDetailsId] = createSignal(null) - const [search, setSearch] = createSignal("") - - // ── Tabs (tracked in ?tab=, like the old app) ─────────── - const isPlayerView = () => vm.currentViewMode() === PreferredView.PLAYER - const tab = createMemo(() => { - const t = query.tab - if (t === "evaluations" || t === "overview") return t - if (t === "connections" && isPlayerView()) return t - if (t === "activity" && !isPlayerView()) return t - return "overview" - }) - const setTab = (t: ProfileTab) => { - setSearch("") - setQuery({ tab: t }) - } - // Leaving the view that owns the current tab resets to overview. - createEffect(() => { - if (query.tab === "connections" && !isPlayerView()) setTab("overview") - if (query.tab === "activity" && isPlayerView()) setTab("overview") - }) - - // ── Data ──────────────────────────────────────────────── - const inbound = useSubjectInboundEvaluations(subjectId, category) - const connections = useSubjectInboundConnections(subjectId) - const activity = useSubjectOutboundEvaluations( - subjectId, - () => ACTIVITY_CATEGORY[vm.currentViewMode()] ?? EvaluationCategory.SUBJECT, - ) - - // View-as options: Subject always; a role once the subject has activity in - // it (same gating as the old header's authorized tabs). - const activityIn = { - [EvaluationCategory.PLAYER]: useSubjectOutboundEvaluations(subjectId, () => EvaluationCategory.PLAYER), - [EvaluationCategory.TRAINER]: useSubjectOutboundEvaluations(subjectId, () => EvaluationCategory.TRAINER), - [EvaluationCategory.MANAGER]: useSubjectOutboundEvaluations(subjectId, () => EvaluationCategory.MANAGER), - } - const authorizedViewAs = createMemo(() => - Object.values(EvaluationCategory).filter( - (c) => - c === EvaluationCategory.SUBJECT || - (activityIn[c as keyof typeof activityIn].evaluations()?.length ?? 0) > 0, - ), - ) - - const profileMissing = () => isNotFound(v.query.error) - const isSelf = () => subjectId() === authStore.user?.brightId - - // ── Search (shared signal, cleared on tab switch) ─────── - const matches = (id: string, name: string) => { - const q = search().trim().toLowerCase() - return !q || name.toLowerCase().includes(q) || id.toLowerCase().includes(q) - } - const filteredEvaluations = createMemo( - () => inbound.evaluations()?.filter((e) => matches(e.evaluatorId, e.name)) ?? [], - ) - const filteredActivity = createMemo( - () => activity.evaluations()?.filter((e) => matches(e.evaluatorId, e.name)) ?? [], - ) - const filteredConnections = createMemo( - () => - connections - .connections() - ?.filter((c) => matches(c.id, connections.nameOf(c.id))) ?? [], - ) - - // Component (not a shared element) — each panel needs its own DOM node. - const SearchInput = () => ( - ) => setSearch(e.detail)} - > - - - ) - - return ( -
- {/* ── Header: title + view-as switcher ── */} -
-
- - ← Back - - {vm.subjectViewModeTitle()} Profile -
-
- - {(c) => ( - vm.updateViewAs(c)} - > - {categoryLabel[c]} - - )} - -
-
- - setEvaluating(subjectId())} - /> - - - - - - {/* ── Evidence tabs ── */} - - ) => - setTab(e.detail.value as ProfileTab) - } - > - Overview - Activity} - > - Connections - - Evaluations - - -
- -
- - {inbound.positiveCount() ?? "-"} - - Positive -
-
- - {inbound.negativeCount() ?? "-"} - - Negative -
-
- - {inbound.evaluations()?.length ?? "-"} - - Total -
-
- - -

- Evaluation impacts — tap a bar for details -

- v.auraImpacts()} - onBarClick={setDetailsId} - /> -
- - - - - - setTab("evaluations")} - > - View evaluations - -
-
- - -
- - - - {(evaluation) => ( - - )} - - -
-
- - - - -
- - - - {(connection) => ( - - )} - - -
-
-
- - -
- - - - {(evaluation) => ( - - )} - - -
-
-
-
-
- - setEvaluating(null)} /> - setDetailsId(null)} /> -
- ) -} +// `/subject/:id` without a role segment — same page, default (Subject) view. +export { default } from "./[viewas]" diff --git a/packages/ui/src/components/tab.ts b/packages/ui/src/components/tab.ts index 3d45cf8..ef4244e 100644 --- a/packages/ui/src/components/tab.ts +++ b/packages/ui/src/components/tab.ts @@ -49,9 +49,13 @@ export class TabsElement extends LitElement { } ` - constructor() { - super() - this.addEventListener("slotchange", () => this._initialize()) + // Re-measure the indicator whenever the tab strip changes size (responsive + // layouts, tabs appearing/disappearing change every sibling's width). + private _resizeObserver = new ResizeObserver(() => this._updateIndicator()) + + disconnectedCallback() { + super.disconnectedCallback() + this._resizeObserver.disconnect() } private _initialize() { @@ -64,6 +68,8 @@ export class TabsElement extends LitElement { } protected firstUpdated(_changedProperties: PropertyValues): void { + const container = this.renderRoot.querySelector(".tab-list") + if (container) this._resizeObserver.observe(container) requestAnimationFrame(() => { if (!this.value && this.tabs.length > 0) { this.value = (this.tabs[0] as any).value || "" @@ -92,14 +98,20 @@ export class TabsElement extends LitElement { return } + // Layout metrics, not getBoundingClientRect: rects are scaled while an + // ancestor animates with `transform: scale()` (e.g. a-dialog opening) and + // ResizeObserver never fires for transforms, so a scaled measurement + // would stick. const container = this.renderRoot.querySelector(".tab-list") as HTMLElement - const tabRect = activeTab.getBoundingClientRect() - const containerRect = container.getBoundingClientRect() - - const left = tabRect.left - containerRect.left + container.scrollLeft - const width = tabRect.width + const styles = getComputedStyle(container) + const gap = parseFloat(styles.columnGap) || 0 + let left = parseFloat(styles.paddingLeft) || 0 + for (const tab of this.tabs) { + if (tab === activeTab) break + left += tab.offsetWidth + gap + } - indicator.style.width = `${width}px` + indicator.style.width = `${activeTab.offsetWidth}px` indicator.style.transform = `translateX(${left}px)` } @@ -147,7 +159,9 @@ export class TabsElement extends LitElement { return html`
- + +
From 1f810535ae0ae80acde3a93eed7e6937a38a6eab Mon Sep 17 00:00:00 2001 From: Bob six Date: Mon, 13 Jul 2026 18:04:39 +0330 Subject: [PATCH 5/6] feat: Refactor evaluation card and subject card to use ImpactStrip component for displaying impacts feat: Enhance Avatar component to support fallback image URLs feat: Update HomeHeader to include role-based icons and colors for view switcher feat: Add loading skeletons for level progress and list components feat: Implement update prompt for PWA with service worker registration and version checking feat: Introduce new SubjectSelfPage for navigating to the user's own profile fix: Improve IndexedDB health check and handle loading states in the splash screen chore: Add role styling utilities for visual identity based on evaluation categories chore: Create a service worker plugin for PWA build process --- .gitignore | 1 + apps/core/PORTING_PLAN.md | 34 ++- apps/core/index.html | 3 + .../core/src/components/charts/chart-help.tsx | 63 +++++ apps/core/src/components/charts/colors.ts | 6 +- .../components/charts/evaluations-chart.tsx | 246 ++++++++++++------ .../src/components/charts/impact-strip.tsx | 56 ++++ .../evaluation/credibility-details.tsx | 10 +- .../components/evaluation/evaluate-modal.tsx | 9 +- .../components/evaluation/evaluation-card.tsx | 49 +--- apps/core/src/components/home/avatar.tsx | 4 +- apps/core/src/components/home/home-header.tsx | 113 +++++--- .../src/components/home/level-progress.tsx | 3 +- .../core/src/components/home/subject-card.tsx | 63 ++--- .../src/components/list/list-skeleton.tsx | 27 ++ apps/core/src/components/list/list-state.tsx | 10 +- .../src/components/settings/version-card.tsx | 13 +- .../core/src/components/shared/app-header.tsx | 31 ++- apps/core/src/components/shared/skeleton.tsx | 18 ++ .../src/components/shared/update-prompt.tsx | 22 ++ .../src/components/subject/evidence-help.tsx | 9 + .../subject/subject-profile-card.tsx | 9 +- apps/core/src/providers.tsx | 2 + apps/core/src/routes/_layout.tsx | 6 +- apps/core/src/routes/home/[view]/levelup.tsx | 16 +- apps/core/src/routes/index.tsx | 116 +++++---- .../core/src/routes/subject/[id]/[viewas].tsx | 32 ++- apps/core/src/routes/subject/index.tsx | 13 + apps/core/src/shared/lib/db-check.ts | 12 + apps/core/src/shared/lib/pwa.ts | 49 ++++ apps/core/src/shared/lib/role-style.ts | 20 ++ apps/core/sw-plugin.ts | 110 ++++++++ apps/core/vite.config.ts | 3 +- 33 files changed, 896 insertions(+), 282 deletions(-) create mode 100644 apps/core/src/components/charts/chart-help.tsx create mode 100644 apps/core/src/components/charts/impact-strip.tsx create mode 100644 apps/core/src/components/list/list-skeleton.tsx create mode 100644 apps/core/src/components/shared/skeleton.tsx create mode 100644 apps/core/src/components/shared/update-prompt.tsx create mode 100644 apps/core/src/routes/subject/index.tsx create mode 100644 apps/core/src/shared/lib/db-check.ts create mode 100644 apps/core/src/shared/lib/pwa.ts create mode 100644 apps/core/src/shared/lib/role-style.ts create mode 100644 apps/core/sw-plugin.ts diff --git a/.gitignore b/.gitignore index 50ac7d7..dc73968 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,4 @@ apps/core-old/src/stores/recovery.ts apps/core-old/src/stores/settings.ts apps/core-old/src/stores/user.ts apps/core-old/src/styles/index.css +.aider* diff --git a/apps/core/PORTING_PLAN.md b/apps/core/PORTING_PLAN.md index 10f80da..623d464 100644 --- a/apps/core/PORTING_PLAN.md +++ b/apps/core/PORTING_PLAN.md @@ -58,17 +58,37 @@ now with search + settings too; bell extracted to `components/shared/notification-bell.tsx`) · subject page honors an injected `?name=` for ids the backup can't resolve. -**Remaining (untracked follow-ups, all optional):** PWA service-worker -update flow + push (settings version card is static) · i18n (old i18next — -copy is English-only, likely fine to drop) · IndexedDB-blocked fallback screen -· subject chart-view / connections help modals (only evidence-help ported) · -param-less `/subject` self-profile fallback · `?gravatar=` injected avatar. +**Also done (aider-assisted pass):** chart help dialog +(`components/charts/chart-help.tsx` — palette strips per evaluator kind, +slotted next to the impact chart caption) · param-less `/subject` → +own-profile redirect (`routes/subject/index.tsx`) · IndexedDB-blocked +fallback screen on the splash (`shared/lib/db-check.ts` + gate in +`routes/index.tsx`) · `?gravatar=` injected avatar (Avatar `fallbackSrc`, +threaded through `subject-profile-card`; direct gravatar image URL — the old +gravatar-profile fetch was dropped). Old connections-help modal not ported: +its content explains smart-sort tiers that core deliberately dropped. + +**Also done:** PWA — dependency-free (registry was unreachable, so no +`vite-plugin-pwa`): `sw-plugin.ts` emits `manifest.webmanifest` + a `sw.js` +that precaches the build (cache-first static, network-first navigation, +SKIP_WAITING activation); `shared/lib/pwa.ts` registers it (prod only, +hourly update poll) and exposes a `needRefresh` signal consumed by the +update toast (`components/shared/update-prompt.tsx`, mounted in providers) +and an Update button on the settings version card. Old push/periodic-sync +notifications NOT ported. + +**Remaining (untracked follow-ups, all optional):** push notifications +(old `prompt-sw.ts` periodicsync + Notification API) · i18n (old i18next — +copy is English-only, likely fine to drop). **Also done:** T2.6 credibility-details modal (collapsed to per-role stat rows, no chart/tabs). -**M2 leftover:** T2.5 evaluations chart (decision: lightweight bars vs lib — -skipped for now). Next up: M3 notifications. +**Status:** all milestones M0–M5 done (task-level notes above). Active work is +polish/bugfixes on `aura/fix/rerender-issues`: chart zoom/pan + no-scroll bars, +passkey node-connection fallback for the evaluate list, apply-time cache +invalidation on settled ops, and the credibility "View profile" nav fix. Only +the optional untracked follow-ups (PWA, i18n) remain. --- diff --git a/apps/core/index.html b/apps/core/index.html index 71e8d2c..21b3dc8 100644 --- a/apps/core/index.html +++ b/apps/core/index.html @@ -3,6 +3,9 @@ + + + Aura }) => ( +
+

{props.label}

+
+ + {(key) => ( +
+ )} + +
+
+ -4 (negative) + +4 (positive) +
+
+ ) + + return ( + + +
+

Reading the impact chart

+ + Each bar is one evaluator; its height is that evaluation's share of + the subject's total impact. Bars below the line are negative + evaluations. Darker shades mean higher confidence. + + + + + + + Learn more + + +
+
+ ) +} diff --git a/apps/core/src/components/charts/colors.ts b/apps/core/src/components/charts/colors.ts index 861c4af..9c154d0 100644 --- a/apps/core/src/components/charts/colors.ts +++ b/apps/core/src/components/charts/colors.ts @@ -5,7 +5,7 @@ */ type ColorMap = Record -const valueColorMap: ColorMap = { +export const valueColorMap: ColorMap = { "-4": "#924848", "-3": "#DA6A6A", "-2": "#EE9D9D", @@ -16,7 +16,7 @@ const valueColorMap: ColorMap = { "4": "#5B9969", } -const userRatingColorMap: ColorMap = { +export const userRatingColorMap: ColorMap = { "-4": "#D9C7F9", "-3": "#C2A8F3", "-2": "#AC89ED", @@ -27,7 +27,7 @@ const userRatingColorMap: ColorMap = { "4": "#451F6D", } -const subjectRatingColorMap: ColorMap = { +export const subjectRatingColorMap: ColorMap = { "-4": "#FAD7A0", "-3": "#F8C471", "-2": "#F5B041", diff --git a/apps/core/src/components/charts/evaluations-chart.tsx b/apps/core/src/components/charts/evaluations-chart.tsx index f8e4791..1a3ef67 100644 --- a/apps/core/src/components/charts/evaluations-chart.tsx +++ b/apps/core/src/components/charts/evaluations-chart.tsx @@ -3,7 +3,10 @@ import { barColor } from "@/components/charts/colors" import ZoomControls from "@/components/charts/zoom-controls" import Avatar from "@/components/home/avatar" import { useNameResolver } from "@/hooks/use-backup" +import { compactFormat } from "@/shared/lib/number" +import { formatDuration } from "@/shared/lib/time" import { authStore } from "@/store/auth" +import { confidenceLabel } from "@aura/domain/labels" import type { AuraImpactRaw } from "@aura/domain/types/aura" // Old chart showed evaluator pictures for up to 20 bars, 16–40px by count. @@ -18,6 +21,10 @@ interface Bar { /** Share of the subject's total absolute impact, signed (-100..100). */ percent: number color: string + /** Raw values carried for the hover tooltip (old recharts tooltip parity). */ + score: number | null + confidence: number + modified: number } /** @@ -26,7 +33,8 @@ interface Bar { * total impact, negatives below the baseline. Bars use the old confidence * palettes (purple = you, orange = the focused subject) and carry the * evaluator's picture underneath when the visible window is small enough. - * Click opens the evaluator. + * Click opens the evaluator; hover shows the old tooltip (score, impact, + * confidence, age). * * The strip never scrolls — bars flex to fill the width and shrink as small as * needed, capped at a max width so a handful of bars don't sprawl. To inspect @@ -38,6 +46,8 @@ export default function EvaluationsChart(props: { onBarClick?: (evaluatorId: string) => void /** Highlighted subject (orange palette), e.g. the profile being viewed. */ focusedSubjectId?: () => string + /** Show a skeleton instead of the empty state while the data loads. */ + loading?: () => boolean }) { const nameOf = useNameResolver() @@ -57,6 +67,9 @@ export default function EvaluationsChart(props: { authStore.user?.brightId, props.focusedSubjectId?.(), ), + score: i.score, + confidence: i.confidence * Math.sign(i.impact), + modified: i.modified, })) }) @@ -118,94 +131,171 @@ export default function EvaluationsChart(props: { } } + // Hovered/focused bar → single floating tooltip (old recharts tooltip). We + // track the bar plus its horizontal center relative to the strip container. + let strip: HTMLDivElement | undefined + const [hovered, setHovered] = createSignal<{ bar: Bar; left: number } | null>( + null, + ) + const showTip = (e: { currentTarget: HTMLElement }, bar: Bar) => { + if (!strip) return + const r = e.currentTarget.getBoundingClientRect() + const p = strip.getBoundingClientRect() + setHovered({ bar, left: r.left - p.left + r.width / 2 }) + } + const signed = (n: number) => (n > 0 ? `+${n}` : String(n)) + return ( 0} + when={!props.loading?.()} fallback={ -
- No evaluation impacts yet. +
+ + {(h) => ( + + )} +
} > -
- 1}> - { - setStart(0) - setEnd(count() - 1) - }} - onZoomIn={() => zoom(1)} - onZoomOut={() => zoom(-1)} - onPanLeft={() => pan(-1)} - onPanRight={() => pan(1)} - disabledZoomIn={end() - start() < 2} - disabledZoomOut={isFull()} - disabledPanLeft={start() === 0} - disabledPanRight={end() === count() - 1} - /> - + 0} + fallback={ +
+ No evaluation impacts yet. +
+ } + > +
+ 1}> + { + setStart(0) + setEnd(count() - 1) + }} + onZoomIn={() => zoom(1)} + onZoomOut={() => zoom(-1)} + onPanLeft={() => pan(-1)} + onPanRight={() => pan(1)} + disabledZoomIn={end() - start() < 2} + disabledZoomOut={isFull()} + disabledPanLeft={start() === 0} + disabledPanRight={end() === count() - 1} + /> + -
- - {(bar) => ( - - )} - + + )} + +
-
+ ) } diff --git a/apps/core/src/components/charts/impact-strip.tsx b/apps/core/src/components/charts/impact-strip.tsx new file mode 100644 index 0000000..f127927 --- /dev/null +++ b/apps/core/src/components/charts/impact-strip.tsx @@ -0,0 +1,56 @@ +import { createMemo, For, Show } from "solid-js" +import { barColor } from "@/components/charts/colors" +import { authStore } from "@/store/auth" +import type { AuraImpactRaw } from "@aura/domain/types/aura" + +/** + * Shared mini impact strip: a subject's top evaluations by |impact| as tiny CSS + * bars in the graded confidence palette (same colors as the big chart, so the + * subject-list and evaluation cards read consistently). Renders nothing when + * there are no impacts. + */ +export default function ImpactStrip(props: { + impacts: () => AuraImpactRaw[] | null + /** Max bars (default 8). */ + max?: number + /** Extra classes, e.g. a height (`h-10`) and margin. */ + class?: string + title?: string + testid?: string +}) { + const bars = createMemo(() => { + const limit = props.max ?? 8 + const impacts = (props.impacts() ?? []) + .filter((i) => i.impact !== 0) + .sort((a, b) => Math.abs(b.impact) - Math.abs(a.impact)) + .slice(0, limit) + const maxAbs = Math.max(...impacts.map((i) => Math.abs(i.impact)), 1) + return impacts.map((i) => ({ + color: barColor( + i.confidence * Math.sign(i.impact), + i.evaluator, + authStore.user?.brightId, + ), + height: Math.max(15, Math.round((Math.abs(i.impact) / maxAbs) * 100)), + })) + }) + + return ( + 0}> +
+ + {(bar) => ( + + )} + +
+
+ ) +} diff --git a/apps/core/src/components/evaluation/credibility-details.tsx b/apps/core/src/components/evaluation/credibility-details.tsx index 114d1bc..41f6bbf 100644 --- a/apps/core/src/components/evaluation/credibility-details.tsx +++ b/apps/core/src/components/evaluation/credibility-details.tsx @@ -10,6 +10,7 @@ import { useSubjectName } from "@/hooks/use-backup" import { useMyRating } from "@/hooks/use-my-evaluations" import { useSubjectInboundEvaluations } from "@/hooks/use-subject-inbound-evaluations" import { useSubjectVerifications } from "@/hooks/use-subject-verifications" +import { roleColor, roleIcon } from "@/shared/lib/role-style" import { authStore } from "@/store/auth" import { categoryLabel, confidenceLabel } from "@aura/domain/labels" import { calculateUserScorePercentage, impactShare } from "@aura/domain/score" @@ -85,6 +86,7 @@ function RoleStats(props: { v.auraImpacts()} onBarClick={props.onNavigate} + loading={() => v.loading()} />
) @@ -155,7 +157,13 @@ export default function CredibilityDetails(props: { {(category) => ( - {categoryLabel[category]} + + + {categoryLabel[category]} + )} diff --git a/apps/core/src/components/evaluation/evaluate-modal.tsx b/apps/core/src/components/evaluation/evaluate-modal.tsx index 541c687..8eb84ee 100644 --- a/apps/core/src/components/evaluation/evaluate-modal.tsx +++ b/apps/core/src/components/evaluation/evaluate-modal.tsx @@ -101,8 +101,12 @@ export default function EvaluateModal(props: { const submit = async () => { const id = props.subjectId() if (!id || isPending()) return + const updating = existing.rating() !== undefined try { await submitEvaluation(id, isYes() ? confidence() : -confidence()) + toast.success(updating ? "Evaluation updated" : "Evaluation submitted", { + description: name(), + }) close() } catch (e) { fail(e) @@ -116,6 +120,7 @@ export default function EvaluateModal(props: { if (!onDelete()) return setOnDelete(true) try { await submitEvaluation(id, 0) + toast.success("Evaluation removed", { description: name() }) close() } catch (e) { fail(e) @@ -138,7 +143,7 @@ export default function EvaluateModal(props: {
setIsYes(true)} @@ -146,7 +151,7 @@ export default function EvaluateModal(props: { Yes props.evaluation const positive = () => e().rating > 0 - /** Top evaluations of the evaluator by |impact|, normalized for the strip. */ - const impactBars = createMemo(() => { - const impacts = (e().impacts ?? []) - .filter((i) => i.impact !== 0) - .sort((a, b) => Math.abs(b.impact) - Math.abs(a.impact)) - .slice(0, MAX_IMPACT_BARS) - const max = Math.max(...impacts.map((i) => Math.abs(i.impact)), 1) - return impacts.map((i) => ({ - // Same graded palette as the big chart: red/green by signed confidence, - // purple for the logged-in user's own bar. - color: barColor( - i.confidence * Math.sign(i.impact), - i.evaluator, - authStore.user?.brightId, - ), - height: Math.max(15, Math.round((Math.abs(i.impact) / max) * 100)), - })) - }) - return ( - 0}> -
- - {(bar) => ( - - )} - -
-
+ e().impacts} + class="mt-1 h-10" + title={`Top evaluations of ${e().name}`} + testid={`evaluation-card-${e().evaluatorId}-chart`} + />
) diff --git a/apps/core/src/components/home/avatar.tsx b/apps/core/src/components/home/avatar.tsx index 3e478f3..dccafe2 100644 --- a/apps/core/src/components/home/avatar.tsx +++ b/apps/core/src/components/home/avatar.tsx @@ -17,6 +17,8 @@ export default function Avatar(props: { noHover?: boolean class?: string style?: JSX.CSSProperties + /** Image URL used when the backup has no photo (e.g. a gravatar link). */ + fallbackSrc?: string }) { const initial = () => (props.name?.trim()?.[0] ?? "?").toUpperCase() @@ -35,7 +37,7 @@ export default function Avatar(props: { // Backup photos are data URIs; tolerate raw base64 just in case. const src = () => { const data = photo.data - if (!data) return undefined + if (!data) return props.fallbackSrc return data.startsWith("data:") ? data : `data:image/jpeg;base64,${data}` } diff --git a/apps/core/src/components/home/home-header.tsx b/apps/core/src/components/home/home-header.tsx index 4206643..339a0bc 100644 --- a/apps/core/src/components/home/home-header.tsx +++ b/apps/core/src/components/home/home-header.tsx @@ -4,7 +4,12 @@ import GlobalSearch from "@/components/search/global-search" import NotificationBell from "@/components/shared/notification-bell" import { useLevelupProgress } from "@/hooks/use-levelup-progress" import { useViewMode } from "@/hooks/use-view-mode" -import { viewModeToSlug, viewModeToString } from "@aura/domain/view-mode" +import { roleColor, roleIcon } from "@/shared/lib/role-style" +import { + viewModeToSlug, + viewModeToString, + viewModeToViewAs, +} from "@aura/domain/view-mode" import { PreferredView } from "@aura/domain/types/dashboard" import { EvaluationCategory } from "@aura/domain/types/evaluations" @@ -34,43 +39,79 @@ export default function HomeHeader() { return true } + const lockMessage = (gate: EvaluationCategory | null) => + gate === EvaluationCategory.MANAGER + ? "Reach the required standing as a Trainer to unlock the Manager view." + : "Reach the required standing as a Player to unlock the Trainer view." + return ( -
- Home -
- - - - - - - +
+
+ Home +
+ + + + + + + +
+
+ + {/* View switcher — scrolls horizontally instead of overflowing on + narrow screens; each button keeps its intrinsic width. */} +
- {({ view, gate }) => ( - - {viewModeToString[view]} - - } - > - - - {viewModeToString[view]} - - - - )} + {({ view, gate }) => { + const label = viewModeToString[view] + const selected = () => vm.currentViewMode() === view + return ( + + + + +
+

{label} locked

+ + {lockMessage(gate)} + +
+ + } + > + + + + {/* Only the active role shows its label — keeps the + switcher compact yet always says where you are. */} + + {label} + + + +
+ ) + }}
diff --git a/apps/core/src/components/home/level-progress.tsx b/apps/core/src/components/home/level-progress.tsx index 696eccb..016318b 100644 --- a/apps/core/src/components/home/level-progress.tsx +++ b/apps/core/src/components/home/level-progress.tsx @@ -1,4 +1,5 @@ import { createMemo, createSignal, Match, Show, Switch } from "solid-js" +import Skeleton from "@/components/shared/skeleton" import RequirementsChecklist, { type LevelRequirement, } from "@/components/home/requirements-checklist" @@ -159,7 +160,7 @@ export default function LevelProgress(props: { subjectId: string }) { } > - ... + diff --git a/apps/core/src/components/home/subject-card.tsx b/apps/core/src/components/home/subject-card.tsx index 5df17b1..7aca7a5 100644 --- a/apps/core/src/components/home/subject-card.tsx +++ b/apps/core/src/components/home/subject-card.tsx @@ -1,5 +1,6 @@ import { useNavigate } from "@solidjs/router" -import { createMemo, For, Show } from "solid-js" +import { createMemo, Show } from "solid-js" +import ImpactStrip from "@/components/charts/impact-strip" import Avatar from "@/components/home/avatar" import ProgressBar from "@/components/home/progress-bar" import LevelScore from "@/components/shared/level-score" @@ -15,7 +16,7 @@ import type { ConnectionLevel, } from "@aura/domain/types/aura" -/** Old app's connection-level palette — used as a status dot. */ +/** Old app's connection-level palette — colors the level chip's icon + text. */ const LEVEL_COLORS: Record = { reported: "#FF4B31", suspicious: "#FF7831", @@ -25,7 +26,15 @@ const LEVEL_COLORS: Record = { "aura only": "#FFE8D4", } -const MAX_IMPACT_BARS = 8 +/** Lucide icon per connection level (old app showed a level icon in the chip). */ +const LEVEL_ICONS: Record = { + reported: "shield-alert", + suspicious: "shield-alert", + "just met": "user", + "already known": "user-check", + recovery: "shield-check", + "aura only": "sparkles", +} /** * Subject row, ported from the old `SubjectCard`: avatar, name, level/score, @@ -54,19 +63,6 @@ export default function SubjectCard(props: { impactShare(v.auraImpacts(), authStore.user?.brightId), ) - /** Top evaluations by |impact|, normalized for the mini bar strip. */ - const impactBars = createMemo(() => { - const impacts = (v.auraImpacts() ?? []) - .filter((i) => i.impact !== 0) - .sort((a, b) => Math.abs(b.impact) - Math.abs(a.impact)) - .slice(0, MAX_IMPACT_BARS) - const max = Math.max(...impacts.map((i) => Math.abs(i.impact)), 1) - return impacts.map((i) => ({ - positive: i.impact > 0, - height: Math.max(15, Math.round((Math.abs(i.impact) / max) * 100)), - })) - }) - return ( navigate(`/subject/${subjectId()}`)} > -
+
@@ -94,12 +90,11 @@ export default function SubjectCard(props: {
- + {toTitleCase(props.connection.level)} @@ -117,7 +112,7 @@ export default function SubjectCard(props: { {(rating() > 0 ? "+" : "") + rating()} } + fallback={} > {confidenceLabel(rating())} @@ -131,22 +126,12 @@ export default function SubjectCard(props: {
- 0}> -
- - {(bar) => ( - - )} - -
-
+ v.auraImpacts()} + class="h-12" + title={`Top evaluations of ${name()}`} + testid={`subject-card-${subjectId()}-chart`} + /> + + {() => ( +
+ +
+ + + +
+ +
+ )} +
+
+ ) +} diff --git a/apps/core/src/components/list/list-state.tsx b/apps/core/src/components/list/list-state.tsx index eef27bf..5d96273 100644 --- a/apps/core/src/components/list/list-state.tsx +++ b/apps/core/src/components/list/list-state.tsx @@ -1,4 +1,5 @@ import { type JSX, Show } from "solid-js" +import ListSkeleton from "@/components/list/list-skeleton" /** Shared loading / empty / content scaffolding for list views. */ export default function ListState(props: { @@ -6,17 +7,20 @@ export default function ListState(props: { empty: boolean emptyText: string children: JSX.Element + /** Override the loading placeholder (defaults to skeleton rows). */ + skeleton?: JSX.Element }) { return ( Loading…
} + fallback={props.skeleton ?? } > - {props.emptyText} +
+ + {props.emptyText}
} > diff --git a/apps/core/src/components/settings/version-card.tsx b/apps/core/src/components/settings/version-card.tsx index 7b85b49..c3010c3 100644 --- a/apps/core/src/components/settings/version-card.tsx +++ b/apps/core/src/components/settings/version-card.tsx @@ -1,6 +1,10 @@ +import { Show } from "solid-js" +import { needRefresh, updateApp } from "@/shared/lib/pwa" + /** - * App version. Simplified from the source — no PWA/service-worker update flow - * yet (the old card checked `/versioning.txt` and prompted a SW update). + * App version card. When the service worker has a new build waiting (see + * `shared/lib/pwa.ts`), an Update button applies it — the old card's + * `/versioning.txt` check is replaced by the SW update signal. */ export default function VersionCard() { return ( @@ -17,6 +21,11 @@ export default function VersionCard() { You are using version {__APP_VERSION__}

+ + + Update + + ) } diff --git a/apps/core/src/components/shared/app-header.tsx b/apps/core/src/components/shared/app-header.tsx index e2a90f6..7794bd1 100644 --- a/apps/core/src/components/shared/app-header.tsx +++ b/apps/core/src/components/shared/app-header.tsx @@ -23,14 +23,35 @@ export default function AppHeader() { ) } + // Derive a page heading from the route so subpages regain the old header + // title (the old `DefaultHeader` always showed one). + const title = () => { + const path = location.pathname + if (path.startsWith("/subject")) return "Profile" + if (path.startsWith("/settings")) return "Settings" + if (path.startsWith("/notifications")) return "Notifications" + if (path.startsWith("/role-management")) return "Roles" + if (path.startsWith("/contact-info")) return "Contact info" + if (path.startsWith("/dashboard")) return "Dashboard" + if (path.startsWith("/domain-overview")) return "Domain" + return "" + } + return (
- - - - - +
+ + + + + + + + {title()} + + +
diff --git a/apps/core/src/components/shared/skeleton.tsx b/apps/core/src/components/shared/skeleton.tsx new file mode 100644 index 0000000..6c43d3a --- /dev/null +++ b/apps/core/src/components/shared/skeleton.tsx @@ -0,0 +1,18 @@ +import type { JSX } from "solid-js" + +/** + * Pulsing placeholder block for loading states. Size/shape it with `class` + * (`h-*`, `w-*`, `rounded-*`) — mirrors the shimmer used in the charts. + */ +export default function Skeleton(props: { + class?: string + style?: JSX.CSSProperties +}) { + return ( +
diff --git a/apps/core/src/components/subject/subject-profile-card.tsx b/apps/core/src/components/subject/subject-profile-card.tsx index c2fdf50..2aa5719 100644 --- a/apps/core/src/components/subject/subject-profile-card.tsx +++ b/apps/core/src/components/subject/subject-profile-card.tsx @@ -13,6 +13,8 @@ export default function SubjectProfileCard(props: { onEvaluate: () => void /** Display name for ids the backup can't resolve (e.g. from `?name=`). */ fallbackName?: () => string | undefined + /** Photo URL for ids the backup can't resolve (e.g. from `?gravatar=`). */ + fallbackPhoto?: () => string | undefined }) { const name = useSubjectName(props.subjectId, props.fallbackName) const vm = useViewMode() @@ -23,7 +25,12 @@ export default function SubjectProfileCard(props: {
- +

{name()} diff --git a/apps/core/src/providers.tsx b/apps/core/src/providers.tsx index e7c046f..89053cc 100644 --- a/apps/core/src/providers.tsx +++ b/apps/core/src/providers.tsx @@ -5,6 +5,7 @@ import localforage from "localforage" import { createEffect, type ParentComponent } from "solid-js" import OpNotifications from "@/components/evaluation/op-notifications" import NotificationsChecker from "@/components/notifications/notifications-checker" +import UpdatePrompt from "@/components/shared/update-prompt" import { preferencesStore } from "@/store/preferences" import { isNotFound } from "@aura/domain/http" @@ -83,6 +84,7 @@ const Providers: ParentComponent = (props) => { onSuccess={() => queryClient.invalidateQueries()} > + {props.children} diff --git a/apps/core/src/routes/_layout.tsx b/apps/core/src/routes/_layout.tsx index fa3eb35..f141093 100644 --- a/apps/core/src/routes/_layout.tsx +++ b/apps/core/src/routes/_layout.tsx @@ -13,8 +13,10 @@ const Layout: ParentComponent = (props) => {

- - {props.children} +
+ + {props.children} +
) } diff --git a/apps/core/src/routes/home/[view]/levelup.tsx b/apps/core/src/routes/home/[view]/levelup.tsx index ffa8705..dfd1ba6 100644 --- a/apps/core/src/routes/home/[view]/levelup.tsx +++ b/apps/core/src/routes/home/[view]/levelup.tsx @@ -46,19 +46,23 @@ export default function HomeLevelUp() {
-
+
Evaluations
-
+
0 + ? `(+${compactFormat(totalPositive())} / -${compactFormat( + totalNegative(), + )})` + : undefined + } />
-
+
)} diff --git a/apps/core/src/routes/index.tsx b/apps/core/src/routes/index.tsx index 7d72aab..a9eb3fb 100644 --- a/apps/core/src/routes/index.tsx +++ b/apps/core/src/routes/index.tsx @@ -1,58 +1,82 @@ import { A } from "@solidjs/router" import FadeIn from "@/components/motions/fade-in" import Scale from "@/components/motions/scale" +import { createResource, Show } from "solid-js" +import { checkIndexedDB } from "@/shared/lib/db-check" const Splash = () => { + const [dbHealthy] = createResource(checkIndexedDB) + return ( -
-
- - - Aura - - - - - Welcome Aura player - - - - - Level up to help your friends and family get Aura verified + + Aura + + IndexedDB is blocked. Please enable IndexedDB to use Aura. - -
+ window.location.reload()} + > + Refresh + +
+ } + > +
+
+ + + Aura + + + + + Welcome Aura player + + + + + Level up to help your friends and family get Aura verified + + +
- aura players + aura players -
- - - - Get Started - - - -
- -
- - Version - {__APP_VERSION__} - - - Powered by: - BrightID - -
-
-
+
+ + + + Get Started + + + +
+ +
+ + Version + {__APP_VERSION__} + + + Powered by: + BrightID + +
+
+
+ ) } diff --git a/apps/core/src/routes/subject/[id]/[viewas].tsx b/apps/core/src/routes/subject/[id]/[viewas].tsx index aa31cf3..e4fe339 100644 --- a/apps/core/src/routes/subject/[id]/[viewas].tsx +++ b/apps/core/src/routes/subject/[id]/[viewas].tsx @@ -1,12 +1,14 @@ import { A, useNavigate, useParams, useSearchParams } from "@solidjs/router" import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" import EvaluationsChart from "@/components/charts/evaluations-chart" +import ChartHelp from "@/components/charts/chart-help" import CredibilityDetails from "@/components/evaluation/credibility-details" import EvaluateModal from "@/components/evaluation/evaluate-modal" import EvaluationCard from "@/components/evaluation/evaluation-card" import ProfileNotFoundHint from "@/components/home/profile-not-found-hint" import IncrementalList from "@/components/list/incremental-list" import ListState from "@/components/list/list-state" +import { roleColor, roleIcon } from "@/shared/lib/role-style" import ConnectionCard from "@/components/subject/connection-card" import EvidenceHelp from "@/components/subject/evidence-help" import SubjectProfileCard from "@/components/subject/subject-profile-card" @@ -135,10 +137,13 @@ export default function SubjectPage() {
{/* ── Header: back link + view-as switcher (each role is a route) ── */}
- - ← Back + + Back -
+
{(c) => ( navigate( `/subject/${subjectId()}/${c}${ @@ -154,7 +161,9 @@ export default function SubjectPage() { ) } > - {categoryLabel[c]} + + {/* Compact: only the active role keeps its label. */} + {categoryLabel[c]} )} @@ -167,6 +176,11 @@ export default function SubjectPage() { fallbackName={() => typeof query.name === "string" ? query.name : undefined } + fallbackPhoto={() => + typeof query.gravatar === "string" && query.gravatar + ? `https://www.gravatar.com/avatar/${query.gravatar}?s=256&d=identicon` + : undefined + } /> @@ -220,12 +234,16 @@ export default function SubjectPage() { -

- Evaluation impacts — tap a bar for details -

+
+

+ Evaluation impacts — tap a bar for details +

+ +
v.auraImpacts()} onBarClick={setDetailsId} + loading={() => v.loading()} />
diff --git a/apps/core/src/routes/subject/index.tsx b/apps/core/src/routes/subject/index.tsx new file mode 100644 index 0000000..bb49fe5 --- /dev/null +++ b/apps/core/src/routes/subject/index.tsx @@ -0,0 +1,13 @@ +import { Navigate } from "@solidjs/router" +import { Show } from "solid-js" +import { useRequireSession } from "@/hooks/use-require-session" + +/** `/subject` with no id — your own profile (old app fell back to self). */ +export default function SubjectSelfPage() { + const subjectId = useRequireSession() + return ( + + {(id) => } + + ) +} diff --git a/apps/core/src/shared/lib/db-check.ts b/apps/core/src/shared/lib/db-check.ts new file mode 100644 index 0000000..a38009c --- /dev/null +++ b/apps/core/src/shared/lib/db-check.ts @@ -0,0 +1,12 @@ +import localforage from "localforage" + +/** True when IndexedDB accepts writes (it's blocked in some private modes). */ +export async function checkIndexedDB(): Promise { + try { + await localforage.setItem("__db_check__", "ok") + await localforage.removeItem("__db_check__") + return true + } catch { + return false + } +} diff --git a/apps/core/src/shared/lib/pwa.ts b/apps/core/src/shared/lib/pwa.ts new file mode 100644 index 0000000..6f7959e --- /dev/null +++ b/apps/core/src/shared/lib/pwa.ts @@ -0,0 +1,49 @@ +import { createSignal } from "solid-js" + +// One-hour poll keeps long-lived tabs finding new deploys (old app's SW +// update cadence). The worker itself is emitted at build time by +// `sw-plugin.ts`; it precaches the bundle and waits for SKIP_WAITING. +const UPDATE_INTERVAL = 60 * 60 * 1000 + +const [needRefresh, setNeedRefresh] = createSignal(false) +let registration: ServiceWorkerRegistration | undefined +let reloading = false + +/** True once a new service-worker build is waiting to activate. */ +export { needRefresh } + +/** Activate the waiting service worker; the controller change reloads us. */ +export function updateApp() { + registration?.waiting?.postMessage("SKIP_WAITING") +} + +/** Register the service worker (production only; safe to call once). */ +export function initPwa() { + if (!import.meta.env.PROD) return + if (registration || !("serviceWorker" in navigator)) return + + // A new worker took over (after updateApp) — load the new bundle. + navigator.serviceWorker.addEventListener("controllerchange", () => { + if (reloading) return + reloading = true + window.location.reload() + }) + + navigator.serviceWorker.register("/sw.js").then((reg) => { + registration = reg + + // "installed" while a controller exists = an update is waiting + // (first-ever install has no controller and needs no prompt). + const watch = (worker: ServiceWorker | null) => + worker?.addEventListener("statechange", () => { + if (worker.state === "installed" && navigator.serviceWorker.controller) + setNeedRefresh(true) + }) + + if (reg.waiting && navigator.serviceWorker.controller) setNeedRefresh(true) + watch(reg.installing) + reg.addEventListener("updatefound", () => watch(reg.installing)) + + setInterval(() => reg.update(), UPDATE_INTERVAL) + }) +} diff --git a/apps/core/src/shared/lib/role-style.ts b/apps/core/src/shared/lib/role-style.ts new file mode 100644 index 0000000..a5f6d7d --- /dev/null +++ b/apps/core/src/shared/lib/role-style.ts @@ -0,0 +1,20 @@ +import { EvaluationCategory } from "@aura/domain/types/evaluations" + +/** + * Per-role visual identity (old app coloured the view switchers / tabs by + * role). `icon` is a lucide name; `color` is a mid-tone from that role's + * confidence palette, used as an accent so the active role reads at a glance. + */ +export const roleIcon: Record = { + [EvaluationCategory.SUBJECT]: "user", + [EvaluationCategory.PLAYER]: "gamepad-2", + [EvaluationCategory.TRAINER]: "dumbbell", + [EvaluationCategory.MANAGER]: "briefcase", +} + +export const roleColor: Record = { + [EvaluationCategory.SUBJECT]: "#E67E22", + [EvaluationCategory.PLAYER]: "#8341DE", + [EvaluationCategory.TRAINER]: "#5B9969", + [EvaluationCategory.MANAGER]: "#3B82F6", +} diff --git a/apps/core/sw-plugin.ts b/apps/core/sw-plugin.ts new file mode 100644 index 0000000..3baac01 --- /dev/null +++ b/apps/core/sw-plugin.ts @@ -0,0 +1,110 @@ +import type { Plugin } from 'vite'; + +/** + * Dependency-free PWA build plugin (stand-in for `vite-plugin-pwa`, which we + * can't vendor): emits `manifest.webmanifest` plus a `sw.js` that precaches + * the build's assets. The service worker is cache-first for same-origin + * static files and network-first for navigations, and activates a new + * version only when the page posts `SKIP_WAITING` (see `src/shared/lib/pwa.ts` + * for the update-prompt side). + */ +export function auraPWA(version: string): Plugin { + return { + name: 'aura-pwa', + apply: 'build', + generateBundle(_options, bundle) { + const assets = Object.keys(bundle) + .filter((file) => file !== 'sw.js') + .map((file) => `/${file}`); + + this.emitFile({ + type: 'asset', + fileName: 'manifest.webmanifest', + source: JSON.stringify(manifest, null, 2), + }); + this.emitFile({ + type: 'asset', + fileName: 'sw.js', + source: swSource(version, assets), + }); + }, + }; +} + +const manifest = { + name: 'Aura', + short_name: 'Aura', + description: 'Aura web app', + display: 'standalone', + theme_color: '#0c0a09', + background_color: '#0c0a09', + start_url: '/', + icons: [ + { + src: '/global/aura-192x192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: '/global/aura-256x256.png', + sizes: '256x256', + type: 'image/png', + purpose: 'any maskable', + }, + { + src: '/global/aura-512x512.png', + sizes: '512x512', + type: 'image/png', + }, + ], +}; + +// Files from public/ aren't in the rollup bundle — precache them explicitly. +const PUBLIC_ASSETS = [ + '/', + '/global/logo.png', + '/global/aura-192x192.png', + '/global/aura-256x256.png', + '/global/aura-512x512.png', +]; + +const swSource = (version: string, assets: string[]) => `// generated by aura-pwa (${version}) +const CACHE = "aura-${version}" +const ASSETS = ${JSON.stringify([...PUBLIC_ASSETS, ...assets])} + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(ASSETS))) +}) + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))), + ) + .then(() => self.clients.claim()), + ) +}) + +// The page decides when a waiting version activates (update prompt). +self.addEventListener("message", (event) => { + if (event.data === "SKIP_WAITING") self.skipWaiting() +}) + +self.addEventListener("fetch", (event) => { + const request = event.request + if (request.method !== "GET") return + const url = new URL(request.url) + if (url.origin !== self.location.origin) return + // Never intercept API proxies — only precached static files are served + // from cache; everything else falls through to the network untouched. + if (request.mode === "navigate") { + event.respondWith(fetch(request).catch(() => caches.match("/"))) + return + } + event.respondWith( + caches.match(request).then((hit) => hit ?? fetch(request)), + ) +}) +`; diff --git a/apps/core/vite.config.ts b/apps/core/vite.config.ts index 863711d..3a3d07e 100644 --- a/apps/core/vite.config.ts +++ b/apps/core/vite.config.ts @@ -3,6 +3,7 @@ import tailwindcss from '@tailwindcss/vite'; import solid from 'vite-plugin-solid'; import { defineConfig, loadEnv } from 'vite'; import pkg from './package.json'; +import { auraPWA } from './sw-plugin'; import { AURA_NODE_PROXY_PATH, AURA_TEST_NODE_PROXY_PATH, @@ -19,7 +20,7 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, __dirname, ''); return { - plugins: [tailwindcss(), solid()], + plugins: [tailwindcss(), solid(), auraPWA(pkg.version)], define: { __APP_VERSION__: JSON.stringify(pkg.version), }, From 66ea85568f9485dff0bc3122889f269014af86c3 Mon Sep 17 00:00:00 2001 From: Bob six Date: Tue, 14 Jul 2026 10:59:16 +0330 Subject: [PATCH 6/6] feat: add dashboard API integration and enhance dialog component behavior - Implemented dashboard API with endpoints for project management and payment processing in `dashboard.ts`. - Updated `index.ts` to export the new dashboard API. - Enhanced `DialogElement` in `dialog.ts` to manage dialog visibility more effectively, including handling reopening during exit animations and ensuring proper cleanup after hiding. --- .../evaluation/credibility-details.tsx | 36 +-- .../components/evaluation/evaluate-modal.tsx | 48 ++-- apps/demo-integration/.env.example | 7 + apps/demo-integration/README.md | 71 ++++++ apps/demo-integration/index.html | 12 + apps/demo-integration/package.json | 24 ++ apps/demo-integration/src/App.tsx | 206 +++++++++++++++ apps/demo-integration/src/main.tsx | 13 + apps/demo-integration/src/styles.css | 235 ++++++++++++++++++ apps/demo-integration/src/vite-env.d.ts | 10 + apps/demo-integration/tsconfig.json | 14 ++ apps/demo-integration/vite.config.ts | 6 + apps/docs/app/components/[slug]/page.tsx | 58 +++++ .../app/components/_components/Controls.tsx | 77 ++++++ .../app/components/_components/CopyButton.tsx | 27 ++ .../components/_components/CssVarsPanel.tsx | 64 +++++ .../app/components/_components/Playground.tsx | 139 +++++++++++ .../app/components/_components/PropsTable.tsx | 37 +++ .../app/components/_components/snippet.ts | 51 ++++ apps/docs/app/components/_registry/badge.ts | 40 +++ apps/docs/app/components/_registry/button.ts | 56 +++++ apps/docs/app/components/_registry/card.ts | 37 +++ apps/docs/app/components/_registry/index.ts | 16 ++ apps/docs/app/components/_registry/input.ts | 34 +++ .../app/components/_registry/separator.ts | 19 ++ apps/docs/app/components/_registry/text.ts | 34 +++ apps/docs/app/components/_registry/types.ts | 39 +++ apps/docs/app/components/layout.tsx | 57 +++++ apps/docs/app/components/page.tsx | 32 +++ apps/docs/app/components/providers.tsx | 13 +- apps/docs/app/globals.css | 23 ++ apps/docs/app/page.tsx | 122 +++------ apps/docs/app/query/_components/Code.tsx | 12 +- apps/docs/aura-ui.d.ts | 33 ++- bun.lock | 171 ++----------- packages/sdk/src/apis/dashboard.ts | 62 +++++ packages/sdk/src/apis/index.ts | 5 + packages/ui/src/components/dialog.ts | 24 +- 38 files changed, 1658 insertions(+), 306 deletions(-) create mode 100644 apps/demo-integration/.env.example create mode 100644 apps/demo-integration/README.md create mode 100644 apps/demo-integration/index.html create mode 100644 apps/demo-integration/package.json create mode 100644 apps/demo-integration/src/App.tsx create mode 100644 apps/demo-integration/src/main.tsx create mode 100644 apps/demo-integration/src/styles.css create mode 100644 apps/demo-integration/src/vite-env.d.ts create mode 100644 apps/demo-integration/tsconfig.json create mode 100644 apps/demo-integration/vite.config.ts create mode 100644 apps/docs/app/components/[slug]/page.tsx create mode 100644 apps/docs/app/components/_components/Controls.tsx create mode 100644 apps/docs/app/components/_components/CopyButton.tsx create mode 100644 apps/docs/app/components/_components/CssVarsPanel.tsx create mode 100644 apps/docs/app/components/_components/Playground.tsx create mode 100644 apps/docs/app/components/_components/PropsTable.tsx create mode 100644 apps/docs/app/components/_components/snippet.ts create mode 100644 apps/docs/app/components/_registry/badge.ts create mode 100644 apps/docs/app/components/_registry/button.ts create mode 100644 apps/docs/app/components/_registry/card.ts create mode 100644 apps/docs/app/components/_registry/index.ts create mode 100644 apps/docs/app/components/_registry/input.ts create mode 100644 apps/docs/app/components/_registry/separator.ts create mode 100644 apps/docs/app/components/_registry/text.ts create mode 100644 apps/docs/app/components/_registry/types.ts create mode 100644 apps/docs/app/components/layout.tsx create mode 100644 apps/docs/app/components/page.tsx create mode 100644 packages/sdk/src/apis/dashboard.ts create mode 100644 packages/sdk/src/apis/index.ts diff --git a/apps/core/src/components/evaluation/credibility-details.tsx b/apps/core/src/components/evaluation/credibility-details.tsx index 41f6bbf..1cccfa7 100644 --- a/apps/core/src/components/evaluation/credibility-details.tsx +++ b/apps/core/src/components/evaluation/credibility-details.tsx @@ -1,6 +1,9 @@ +import { categoryLabel, confidenceLabel } from "@aura/domain/labels" +import { calculateUserScorePercentage, impactShare } from "@aura/domain/score" +import { EvaluationCategory } from "@aura/domain/types/evaluations" +import type { DialogElement } from "@aura/ui" import { useNavigate } from "@solidjs/router" import { createEffect, createMemo, createSignal, For, Show } from "solid-js" -import type { DialogElement } from "@aura/ui" import EvaluationsChart from "@/components/charts/evaluations-chart" import EvaluateModal from "@/components/evaluation/evaluate-modal" import Avatar from "@/components/home/avatar" @@ -12,9 +15,6 @@ import { useSubjectInboundEvaluations } from "@/hooks/use-subject-inbound-evalua import { useSubjectVerifications } from "@/hooks/use-subject-verifications" import { roleColor, roleIcon } from "@/shared/lib/role-style" import { authStore } from "@/store/auth" -import { categoryLabel, confidenceLabel } from "@aura/domain/labels" -import { calculateUserScorePercentage, impactShare } from "@aura/domain/score" -import { EvaluationCategory } from "@aura/domain/types/evaluations" /** One role's stats panel: standing, your evaluation, impacts chart. */ function RoleStats(props: { @@ -92,12 +92,6 @@ function RoleStats(props: { ) } -/** - * Credibility breakdown dialog for a subject (usually an evaluator tapped in - * a list). Ported from the old `CredibilityDetailsModal`: one tab per role - * the subject has unlocked (Subject always), each with standing, your - * evaluation (editable inline) and the impacts chart. - */ export default function CredibilityDetails(props: { subjectId: () => string | null onClose: () => void @@ -108,8 +102,6 @@ export default function CredibilityDetails(props: { const id = () => props.subjectId() ?? "" const name = useSubjectName(id) - // Role tabs are gated like the old modal: Subject always, others once the - // subject has a level in them. All four reads share one profile query. const roleChecks = Object.values(EvaluationCategory).map((category) => ({ category, v: useSubjectVerifications(id, () => category), @@ -123,29 +115,23 @@ export default function CredibilityDetails(props: { .map(({ category }) => category), ) - // Inline evaluate (old modal's pencil / "Evaluate Now"): track which role - // is being evaluated; null keeps the nested dialog closed. const [evaluatingCategory, setEvaluatingCategory] = createSignal(null) - // Chart bar tap: close the dialog and jump to that evaluator (old behavior). const goTo = (subjectId: string) => { - props.onClose() + dialog?.hide() navigate(`/subject/${subjectId}`) } - createEffect(() => { if (props.subjectId()) dialog?.show() else dialog?.hide() }) - const onOpenChange = (e: CustomEvent<{ open: boolean }>) => { - if (!e.detail.open && props.subjectId()) props.onClose() - } - return ( <> - + {/* State clears only after the leave animation completes, so the body + never re-renders to its empty state mid-exit. */} + props.onClose()}>
@@ -180,10 +166,6 @@ export default function CredibilityDetails(props: { - - {/* Navigate via goTo, which captures the id *before* onClose nulls - props.subjectId() — a reactive `/subject/${id()}` href would - otherwise collapse to `/subject/` on click and 404. */} - {/* Sibling overlay — a-dialog only projects its named slots, and a - later fixed overlay stacks above the credibility dialog. */} (evaluatingCategory() ? id() : null)} category={() => evaluatingCategory() ?? EvaluationCategory.SUBJECT} diff --git a/apps/core/src/components/evaluation/evaluate-modal.tsx b/apps/core/src/components/evaluation/evaluate-modal.tsx index 8eb84ee..d2cd9eb 100644 --- a/apps/core/src/components/evaluation/evaluate-modal.tsx +++ b/apps/core/src/components/evaluation/evaluate-modal.tsx @@ -1,16 +1,16 @@ -import { createEffect, createSignal, For, Show } from "solid-js" -import { toast } from "@aura/ui" -import type { DialogElement } from "@aura/ui" -import { useEvaluateSubject } from "@/hooks/use-evaluate-subject" -import { useMyRating } from "@/hooks/use-my-evaluations" -import { useSubjectName } from "@/hooks/use-backup" -import { useViewMode } from "@/hooks/use-view-mode" import { - categoryLabel, CONFIDENCE_LABELS, + categoryLabel, confidenceLabel, } from "@aura/domain/labels" import { EvaluationCategory } from "@aura/domain/types/evaluations" +import type { DialogElement } from "@aura/ui" +import { toast } from "@aura/ui" +import { createEffect, createSignal, For, Show } from "solid-js" +import { useSubjectName } from "@/hooks/use-backup" +import { useEvaluateSubject } from "@/hooks/use-evaluate-subject" +import { useMyRating } from "@/hooks/use-my-evaluations" +import { useViewMode } from "@/hooks/use-view-mode" const CONFIDENCE_VALUES = Object.keys(CONFIDENCE_LABELS).map(Number) @@ -26,35 +26,35 @@ const QUESTIONS: Record = { "Does this Manager accurately and honestly evaluate Managers and Trainers in the BrightID domain?", } -const EXPRESSIONS: Record = { +const EXPRESSIONS: Record< + EvaluationCategory, + { positive: string; negative: string } +> = { [EvaluationCategory.SUBJECT]: { positive: "… this account should be verified.", negative: "… this account should not be verified.", }, [EvaluationCategory.PLAYER]: { positive: "… this Player accurately and honestly evaluates Subjects.", - negative: "… this Player does not accurately and honestly evaluate Subjects.", + negative: + "… this Player does not accurately and honestly evaluate Subjects.", }, [EvaluationCategory.TRAINER]: { positive: "… this Trainer accurately and honestly evaluates Players.", - negative: "… this Trainer does not accurately and honestly evaluate Players.", + negative: + "… this Trainer does not accurately and honestly evaluate Players.", }, [EvaluationCategory.MANAGER]: { - positive: "… this Manager accurately and honestly evaluates Managers and Trainers.", - negative: "… this Manager does not accurately and honestly evaluate Managers and Trainers.", + positive: + "… this Manager accurately and honestly evaluates Managers and Trainers.", + negative: + "… this Manager does not accurately and honestly evaluate Managers and Trainers.", }, } -/** - * Controlled evaluation dialog, ported from the old `EvaluateModalBody`: - * category question, Yes/No, confidence, the "what this means" expression - * line, and — when an evaluation already exists — Update plus a two-step - * Remove (confidence-0 operation, which the node treats as deletion). - */ export default function EvaluateModal(props: { subjectId: () => string | null onClose: () => void - /** Override the evaluation category (defaults to the current view's). */ category?: () => EvaluationCategory }) { let dialog: DialogElement | undefined @@ -84,11 +84,7 @@ export default function EvaluateModal(props: { } }) - const close = () => props.onClose() - - const onOpenChange = (e: CustomEvent<{ open: boolean }>) => { - if (!e.detail.open && props.subjectId()) close() - } + const close = () => dialog?.hide() const fail = (e: unknown) => toast.error("Error", { @@ -128,7 +124,7 @@ export default function EvaluateModal(props: { } return ( - + props.onClose()}>
diff --git a/apps/demo-integration/.env.example b/apps/demo-integration/.env.example new file mode 100644 index 0000000..7c9610f --- /dev/null +++ b/apps/demo-integration/.env.example @@ -0,0 +1,7 @@ +# Origin that serves the Aura verification embed (/embed/projects/:id). +# Defaults to production. Point at http://localhost:5173 to test against a +# locally running interface app (bun dev in apps/interface). +VITE_AURA_EMBED_BASE_URL=https://aura-get-verified.vercel.app + +# Project to verify against (see the dashboard for project ids). Dev page uses 9. +VITE_AURA_PROJECT_ID=9 diff --git a/apps/demo-integration/README.md b/apps/demo-integration/README.md new file mode 100644 index 0000000..f004ceb --- /dev/null +++ b/apps/demo-integration/README.md @@ -0,0 +1,71 @@ +# @aura/demo-integration + +A minimal standalone app that shows how a **third-party site** integrates Aura +verification by embedding the interface iframe and receiving the verification +**signature** over `postMessage`. It mirrors the reference `/dev` page in the +interface app (`apps/interface/src/routes/dev.ts`) but as an external integrator +rather than same-origin code. + +## What it does + +1. Embeds `