diff --git a/AGENTS.md b/AGENTS.md index 8f864b3a..bef49149 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ npm run lint # ESLint - Feedback button avoids footer overlap via scroll listener on `.landing-footer` ### SEO / GEO -- JSON-LD: Organization + WebSite in `layout.tsx`, SoftwareApplication + FAQPage in `welcome/page.tsx` +- JSON-LD: Organization (schema.org) + WebSite in `layout.tsx`, SoftwareApplication + FAQPage in `welcome/page.tsx` - OG/Twitter meta: defaults in `layout.tsx`, overrides per page - Title template: `"%s — Certified"` (layout), pages export specific titles - `robots.ts`, `sitemap.ts`, `manifest.ts` in `src/app/` @@ -109,12 +109,12 @@ src/ │ ├── connected-apps/ │ │ ├── layout.tsx # AuthGuard │ │ └── page.tsx -│ ├── organizations/ # Org management routes +│ ├── groups/ # Group management routes │ ├── oauth/callback/page.tsx # OAuth redirect handler │ ├── api/ │ │ ├── auth/ # login, logout, session, callback-handler │ │ ├── xrpc/[...method]/route.ts # XRPC proxy with security -│ │ ├── organizations/ # Org API routes +│ │ ├── groups/ # Group API routes │ │ ├── feedback/route.ts # Feedback email via Resend │ │ ├── resolve-handle/route.ts │ │ ├── resolve-did/route.ts @@ -135,7 +135,7 @@ src/ │ │ ├── sidebar.tsx # Left nav for settings │ │ └── auth-guard.tsx # Auth redirect with returnTo │ ├── dashboard/ # Dashboard cards and lists -│ ├── organizations/ # Org management components +│ ├── groups/ # Group management components │ ├── profile/ # Profile editing │ ├── identity-link/ # Wallet attestation │ ├── account/ # Email and password sections @@ -144,14 +144,14 @@ src/ ├── hooks/ │ ├── use-session.ts # Cached session data (handle, email) │ ├── use-profile.ts # Profile data with AbortController -│ ├── use-org-profile.ts # Organization profile +│ ├── use-org-profile.ts # Group profile │ ├── use-identity-links.ts # Wallet attestations │ ├── use-attestation-signing.ts # EIP-712 signing │ └── use-focus-trap.ts # Modal focus trapping │ └── lib/ ├── auth/ # OAuth client, session, CSRF, fetch wrapper - ├── organizations/ # Org API, context, constants, types + ├── groups/ # Group API, context, constants, types ├── identity-link/ # EIP-712 attestation, PDS read/write ├── atproto/ # DID resolution, profile fetch/update ├── constants/apps.ts # CONNECTED_APPS (single source of truth) diff --git a/GEO-AUDIT-REPORT.md b/GEO-AUDIT-REPORT.md deleted file mode 100644 index 30e93df7..00000000 --- a/GEO-AUDIT-REPORT.md +++ /dev/null @@ -1,299 +0,0 @@ -# GEO Audit Report: certified.app - -**Date:** 2026-04-06 -**URL:** https://certified.app -**Business Type:** SaaS — Identity & Authentication Platform -**Operated By:** Hypercerts Foundation - ---- - -## Composite GEO Score: 20/100 (Critical) - -| Category | Weight | Score | Weighted | -|----------|--------|-------|----------| -| AI Citability & Visibility | 25% | 14/100 | 3.5 | -| Brand Authority Signals | 20% | 12/100 | 2.4 | -| Content Quality & E-E-A-T | 20% | 28/100 | 5.6 | -| Technical Foundations | 15% | 38/100 | 5.7 | -| Structured Data | 10% | 5/100 | 0.5 | -| Platform Optimization | 10% | 18/100 | 1.8 | -| **TOTAL** | **100%** | | **19.5 -> 20** | - -**Score Interpretation:** The site is virtually invisible to AI search engines. The homepage — the only marketing content page — is entirely client-rendered and produces a blank loading spinner for all AI crawlers. There is no robots.txt, no sitemap, no llms.txt, no structured data, and near-zero brand presence on platforms AI models use for entity recognition. - ---- - -## Platform Readiness - -| Platform | Score | Status | -|----------|-------|--------| -| Google AI Overviews | 22/100 | Poor | -| Google Gemini | 16/100 | Critical | -| Bing Copilot | 15/100 | Critical | -| Perplexity AI | 14/100 | Critical | -| ChatGPT Web Search | 12/100 | Critical | - ---- - -## Top Findings - -### CRITICAL — Homepage is invisible to AI crawlers - -The homepage component (`src/components/landing/home-client.tsx`) is marked `"use client"` and loads all sections via `next/dynamic`. The raw HTML that AI crawlers receive is: - -```html -
-
- -
- -
-``` - -No headings, no text, no links. GPTBot, ClaudeBot, PerplexityBot, and all other AI crawlers see **nothing**. The Terms, Privacy, and DSA pages are correctly server-rendered. - -### CRITICAL — No crawl infrastructure exists - -| File | Status | -|------|--------| -| robots.txt | 404 — Missing | -| sitemap.xml | 404 — Missing | -| llms.txt | 404 — Missing | -| canonical tags | Missing on all pages | -| OG/Twitter meta | Missing on all pages | - -### CRITICAL — Zero structured data - -No JSON-LD, no Microdata, no RDFa on any page. Missing: Organization, SoftwareApplication, WebSite, FAQPage, BreadcrumbList, speakable. AI models have no structured way to understand what Certified is. - -### CRITICAL — Near-zero brand recognition for AI models - -| Platform | Present? | -|----------|----------| -| Wikipedia/Wikidata | No | -| Reddit | No | -| YouTube | No | -| LinkedIn | Unknown | -| GitHub | Yes (34 repos, 47 followers) | -| Product Hunt / G2 / Capterra | No | - -The brand name "Certified" is a common English word, making entity disambiguation nearly impossible without strong external signals. - -### HIGH — Thin content with weak E-E-A-T - -- Homepage: ~250 words of marketing copy (well below 300-word minimum) -- Total site content: ~7,050 words, of which ~6,800 are legal text -- No about page, no blog, no docs, no changelog -- No author bylines, no team credentials -- Zero statistics or data points anywhere -- No product screenshots - -### HIGH — Subpage metadata is generic - -Pages `/terms`, `/privacy`, and `/dsa` inherit the root layout defaults: -- Title: "Certified" -- Description: "Your identity, everywhere." - -These should have unique, page-specific titles and descriptions. - ---- - -## E-E-A-T Breakdown - -| Dimension | Score | Key Gap | -|-----------|-------|---------| -| Experience | 3/25 | No case studies, no original data, no first-hand accounts | -| Expertise | 7/25 | No author bylines, no technical depth on landing page | -| Authoritativeness | 6/25 | No about page, no external citations, no media mentions | -| Trustworthiness | 14/25 | Strong legal pages, HTTPS, but no contact info on homepage | - ---- - -## Citability Assessment - -The highest-scoring content blocks are FAQ answers at 24-26/100. No content block on the site reaches the citation-ready threshold of 70/100. Key problems: - -- Zero statistical density across the entire site -- No self-contained explanatory paragraphs (everything is 1-sentence fragments) -- No "What is Certified?" definition block -- Client-side rendering makes even the best content invisible to crawlers - ---- - -## Technical Details - -| Check | Result | -|-------|--------| -| HTTPS | Yes (required for .app TLD) | -| HSTS | Present (max-age=63072000) | -| CSP | Missing | -| X-Frame-Options | Missing | -| X-Content-Type-Options | Missing | -| SSR | Homepage: No (CSR bailout). Subpages: Yes | -| Framework | Next.js App Router on Vercel | -| Mobile | Good (Tailwind responsive, viewport meta correct) | -| URL structure | Excellent (clean, flat, descriptive) | -| LCP risk | High (homepage requires full JS execution) | -| CLS risk | Medium (no image dimensions, dynamic imports) | - ---- - -## Prioritized Action Plan - -### Quick Wins (Low effort, high impact) - -| # | Action | Impact | Platforms Affected | -|---|--------|--------|--------------------| -| 1 | Create `src/app/robots.ts` allowing all crawlers + sitemap reference | Crawl infrastructure | All 5 | -| 2 | Create `src/app/sitemap.ts` listing all public routes | Page discovery | All 5 | -| 3 | Add `public/llms.txt` describing the site for AI systems | AI context | ChatGPT, Perplexity, Claude | -| 4 | Add page-specific metadata to /terms, /privacy, /dsa | Indexability | All 5 | -| 5 | Add OG and Twitter Card meta tags to all pages | Social/AI previews | All 5 | -| 6 | Add canonical URLs to all pages | Duplicate prevention | All 5 | -| 7 | Link to GitHub org from footer | Entity signal (Microsoft property) | Bing Copilot, ChatGPT | - -### Medium-Term (Medium effort, critical impact) - -| # | Action | Impact | Platforms Affected | -|---|--------|--------|--------------------| -| 8 | **Fix homepage SSR** — extract landing sections from `"use client"` HomeClient into server components. Keep only interactive elements (sign-in, FAQ accordion) as client islands. This is the #1 priority. | Makes all landing content visible to crawlers | All 5 | -| 9 | Add JSON-LD structured data: Organization (Hypercerts Foundation + sameAs), SoftwareApplication (Certified), WebSite, FAQPage, BreadcrumbList | Entity recognition | All 5 | -| 10 | Create an `/about` page (800-1200 words): who operates Certified, team, mission, AT Protocol explanation, founding story | E-E-A-T, citability | All 5 | -| 11 | Add a "What is Certified?" definition block to the homepage — 2-3 self-contained sentences an AI can extract as a canonical answer | Citability | All 5 | -| 12 | Add statistics and specificity: partner app count, account creation time, protocol details, uptime, any measurable claims | Citability | All 5 | -| 13 | Add security headers (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) via next.config.ts | Security/trust | Google, Bing | -| 14 | Register with Bing Webmaster Tools + implement IndexNow | Bing indexing | Bing Copilot | - -### Strategic (High effort, long-term impact) - -| # | Action | Impact | Platforms Affected | -|---|--------|--------|--------------------| -| 15 | Pursue Wikipedia/Wikidata entry for Hypercerts Foundation | Entity recognition (strongest signal) | ChatGPT, Perplexity, Gemini | -| 16 | Build content hub: blog/docs covering "What is AT Protocol?", "Decentralized identity explained", "Certified vs. Sign in with Google", etc. | Topical authority | All 5 | -| 17 | Create YouTube explainer video(s) | Google ecosystem presence | Gemini, Google AIO | -| 18 | Seed community presence: Reddit (r/selfhosted, r/privacy, r/atproto), Product Hunt, developer forums | Brand mentions | Perplexity, ChatGPT | -| 19 | Consider a more distinctive brand qualifier (e.g., "Certified by Hypercerts" or "Certified ID") in public-facing contexts | Entity disambiguation | All 5 | - ---- - -## Recommended JSON-LD (Ready to Implement) - -### Organization — Root Layout - -```json -{ - "@context": "https://schema.org", - "@type": "Organization", - "name": "Hypercerts Foundation", - "legalName": "Hypercerts Foundation", - "url": "https://hypercerts.org", - "logo": "https://certified.app/assets/certified_brandmark_black.png", - "description": "A Delaware nonstock corporation that develops open infrastructure for the hypercerts ecosystem, operating the Certified identity platform.", - "address": { - "@type": "PostalAddress", - "streetAddress": "1209 Orange St.", - "addressLocality": "Wilmington", - "addressRegion": "DE", - "postalCode": "19801", - "addressCountry": "US" - }, - "contactPoint": { - "@type": "ContactPoint", - "email": "legal@hypercerts.org", - "contactType": "legal" - }, - "sameAs": [ - "https://github.com/hypercerts-org" - ] -} -``` - -### SoftwareApplication — Homepage - -```json -{ - "@context": "https://schema.org", - "@type": "SoftwareApplication", - "name": "Certified", - "url": "https://certified.app", - "applicationCategory": "SecurityApplication", - "operatingSystem": "Web", - "description": "Create your Certified identity and use one account across partner apps. No passwords, no lock-in. Built on AT Protocol for decentralized identity.", - "offers": { - "@type": "Offer", - "price": "0", - "priceCurrency": "USD" - }, - "creator": { - "@type": "Organization", - "name": "Hypercerts Foundation", - "url": "https://hypercerts.org" - }, - "isAccessibleForFree": true -} -``` - -### WebSite — Root Layout - -```json -{ - "@context": "https://schema.org", - "@type": "WebSite", - "name": "Certified", - "url": "https://certified.app", - "description": "Create your Certified identity and use one account across partner apps. No passwords, no lock-in.", - "publisher": { - "@type": "Organization", - "name": "Hypercerts Foundation" - }, - "inLanguage": "en" -} -``` - ---- - -## Recommended llms.txt - -``` -# Certified - -> Certified is an identity platform built on AT Protocol, operated by Hypercerts Foundation. It provides passwordless authentication and portable user profiles across partner applications. - -## Main - -- [Home](https://certified.app): Create your Certified identity and use one account across partner apps -- [Terms of Service](https://certified.app/terms): Service terms for Certified and certified.one infrastructure -- [Privacy Policy](https://certified.app/privacy): Data processing practices, GDPR compliance, cookie policy -- [DSA Compliance](https://certified.app/dsa): Digital Services Act compliance information - -## About - -- [Hypercerts Foundation](https://hypercerts.org): Parent organization building open-source protocols for impact funding -- [GitHub](https://github.com/hypercerts-org): Open source repositories for Certified and related projects - -## Partner Apps - -- [Ma Earth](https://maearth.com): Collective funding for regenerating Earth -- [GainForest](https://gainforest.earth): AI-powered forest monitoring and conservation rewards -- [Simocracy](https://simocracy.org): Democratic governance with verifiable identity -- [Hyperboards](https://hyperboards.org): Visual leaderboards for impact contributions -``` - ---- - -## Key Source Files - -| File | Issue | -|------|-------| -| `src/components/landing/home-client.tsx` | `"use client"` — causes SSR bailout, all landing content invisible to crawlers | -| `src/app/page.tsx` | Renders HomeClient — homepage has no server-rendered content | -| `src/app/layout.tsx` | Missing: OG tags, Twitter cards, canonical, JSON-LD, security headers | -| `src/app/terms/page.tsx` | No metadata export (inherits generic defaults) | -| `src/app/privacy/page.tsx` | No metadata export (inherits generic defaults) | -| `src/app/dsa/page.tsx` | No metadata export (inherits generic defaults) | -| `next.config.ts` | No security headers configured | -| `public/` | Missing: robots.txt, sitemap.xml, llms.txt | - ---- - -*Generated by GEO Audit Tool — 2026-04-06* diff --git a/README.md b/README.md index 46a35b6f..af8d3471 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ When a user signs up, they get an AT Protocol identity and a Personal Data Serve | `/` | Profile dashboard (authenticated) | | `/settings` | Account settings | | `/connected-apps` | Manage connected applications | -| `/organizations` | Organization management | +| `/groups` | Group management | | `/about` | About Certified and the Hypercerts Foundation | | `/terms` | Terms of Service | | `/privacy` | Privacy Policy | diff --git a/certified-design.pen b/certified-design.pen new file mode 100644 index 00000000..c080a22b --- /dev/null +++ b/certified-design.pen @@ -0,0 +1,17 @@ +{ + "version": "2.10", + "children": [ + { + "type": "frame", + "id": "bi8Au", + "x": 0, + "y": 0, + "name": "Frame", + "clip": true, + "width": 800, + "height": 600, + "fill": "#FFFFFF", + "layout": "none" + } + ] +} \ No newline at end of file diff --git a/certified-v2.pen b/certified-v2.pen deleted file mode 100644 index 81814bf7..00000000 --- a/certified-v2.pen +++ /dev/null @@ -1,1870 +0,0 @@ -{ - "version": "2.8", - "children": [ - { - "type": "frame", - "id": "fRwnH", - "x": 0, - "y": 0, - "name": "Certified - Profile Manager", - "clip": true, - "width": 1440, - "height": 1260, - "fill": "$--bg-page", - "children": [ - { - "type": "frame", - "id": "3mmi2", - "name": "Sidebar", - "width": 280, - "height": "fill_container", - "fill": "$--bg-sidebar", - "layout": "vertical", - "padding": [ - 24, - 20 - ], - "children": [ - { - "type": "frame", - "id": "VXmP3", - "name": "Logo", - "width": "fill_container", - "gap": 10, - "padding": [ - 0, - 0, - 24, - 0 - ], - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "mGAhp", - "name": "Wordmark", - "gap": 8, - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "lWpaE", - "name": "checkIcon", - "width": 24, - "height": 24, - "iconFontName": "shield-check", - "iconFontFamily": "lucide", - "fill": "#60A1E2" - }, - { - "type": "text", - "id": "ANith", - "name": "wordmarkText", - "fill": "#FFFFFF", - "content": "Certified", - "fontFamily": "DM Serif Display", - "fontSize": 20, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "type": "frame", - "id": "y6lkl", - "name": "Nav Section", - "width": "fill_container", - "height": "fill_container", - "layout": "vertical", - "gap": 2, - "children": [ - { - "type": "text", - "id": "1P4hz", - "name": "navSectionLabel", - "fill": "$--fg-on-dark-muted", - "content": "ACCOUNT", - "fontFamily": "DM Sans", - "fontSize": 11, - "fontWeight": "600", - "letterSpacing": 1.2 - }, - { - "id": "nPHLL", - "type": "ref", - "ref": "lQjfT", - "width": "fill_container", - "name": "navProfile", - "descendants": { - "fylsY": { - "iconFontName": "user" - }, - "EWTGJ": { - "content": "Profile" - } - } - }, - { - "id": "pxZmZ", - "type": "ref", - "ref": "m3saR", - "width": "fill_container", - "name": "navSecurity", - "descendants": { - "5PuVb": { - "iconFontName": "shield" - }, - "6f3Km": { - "content": "Security" - } - } - }, - { - "id": "H4inJ", - "type": "ref", - "ref": "m3saR", - "width": "fill_container", - "name": "navApps", - "descendants": { - "5PuVb": { - "iconFontName": "layout-grid" - }, - "6f3Km": { - "content": "Connected Apps" - } - } - }, - { - "id": "2G7D7", - "type": "ref", - "ref": "m3saR", - "width": "fill_container", - "name": "navData", - "descendants": { - "5PuVb": { - "iconFontName": "database" - }, - "6f3Km": { - "content": "Data & Privacy" - } - } - }, - { - "id": "cMBY8", - "type": "ref", - "ref": "m3saR", - "width": "fill_container", - "name": "navIdentity", - "descendants": { - "5PuVb": { - "iconFontName": "fingerprint" - }, - "6f3Km": { - "content": "Identity" - } - } - } - ] - }, - { - "type": "frame", - "id": "fkSmb", - "name": "User Profile", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "top": 1 - }, - "fill": "#1A3560" - }, - "gap": 12, - "padding": [ - 16, - 0, - 0, - 0 - ], - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "WhGHs", - "name": "avatar", - "width": 36, - "height": 36, - "fill": "$--brand-accent", - "cornerRadius": 100, - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "V40vK", - "name": "avatarText", - "fill": "$--fg-on-dark", - "content": "JD", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "GZZpM", - "name": "userInfo", - "width": "fill_container", - "layout": "vertical", - "gap": 1, - "children": [ - { - "type": "text", - "id": "fE0mh", - "name": "userName", - "fill": "$--fg-on-dark", - "content": "Jane Doe", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "500" - }, - { - "type": "text", - "id": "NDp7D", - "name": "userHandle", - "fill": "$--fg-on-dark-muted", - "content": "@jane.certified.id", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "normal" - } - ] - }, - { - "type": "icon_font", - "id": "mkRdp", - "name": "logoutIcon", - "width": 18, - "height": 18, - "iconFontName": "log-out", - "iconFontFamily": "lucide", - "fill": "$--fg-on-dark-muted" - } - ] - } - ] - }, - { - "type": "frame", - "id": "kQlV6", - "name": "Main Content", - "width": "fill_container", - "height": "fill_container", - "layout": "vertical", - "children": [ - { - "type": "frame", - "id": "RmZzG", - "name": "Header Bar", - "width": "fill_container", - "height": 64, - "fill": "$--bg-card", - "stroke": { - "align": "inside", - "thickness": { - "bottom": 1 - }, - "fill": "$--border" - }, - "padding": [ - 0, - 32 - ], - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "O3RPp", - "name": "headerLeft", - "gap": 8, - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "x1mSd", - "name": "headerTitle", - "fill": "$--fg-primary", - "content": "Profile", - "fontFamily": "DM Serif Display", - "fontSize": 22, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "8FipO", - "name": "headerRight", - "gap": 12, - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "pilTt", - "name": "searchBox", - "width": 220, - "height": 36, - "fill": "$--bg-muted", - "cornerRadius": "$--radius-pill", - "gap": 8, - "padding": [ - 8, - 14 - ], - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "9BdN6", - "name": "searchIcon", - "width": 16, - "height": 16, - "iconFontName": "search", - "iconFontFamily": "lucide", - "fill": "$--fg-muted" - }, - { - "type": "text", - "id": "1b4eI", - "name": "searchText", - "fill": "$--fg-muted", - "content": "Search settings...", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "x6ut8", - "name": "bellIcon", - "width": 36, - "height": 36, - "cornerRadius": 100, - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "o0yfR", - "name": "bellImg", - "width": 20, - "height": 20, - "iconFontName": "bell", - "iconFontFamily": "lucide", - "fill": "$--fg-secondary" - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "NOjop", - "name": "Content Area", - "width": "fill_container", - "height": "fill_container", - "gap": 32, - "padding": 32, - "children": [ - { - "type": "frame", - "id": "PHqKs", - "name": "Left Column", - "width": "fill_container", - "layout": "vertical", - "gap": 24, - "children": [ - { - "type": "frame", - "id": "V16e2", - "name": "Profile Card", - "width": "fill_container", - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "layout": "vertical", - "gap": 24, - "padding": 28, - "children": [ - { - "type": "frame", - "id": "YhzFQ", - "name": "Profile Header", - "width": "fill_container", - "gap": 20, - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "m4d3I", - "name": "avatarLarge", - "width": 72, - "height": 72, - "fill": "$--brand-accent", - "cornerRadius": 100, - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "QQnwD", - "name": "avatarInit", - "fill": "$--fg-on-dark", - "content": "JD", - "fontFamily": "DM Sans", - "fontSize": 24, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "K6O4W", - "name": "profileInfo", - "width": "fill_container", - "layout": "vertical", - "gap": 4, - "children": [ - { - "type": "text", - "id": "LzrY8", - "name": "profileName", - "fill": "$--fg-primary", - "content": "Jane Doe", - "fontFamily": "DM Serif Display", - "fontSize": 24, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "Jk9dy", - "name": "profileHandle", - "fill": "$--fg-secondary", - "content": "@jane.certified.id", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "6OjPz", - "name": "profileBio", - "fill": "$--fg-secondary", - "textGrowth": "fixed-width", - "width": "fill_container", - "content": "Product designer and open web advocate. Building for the decentralized future.", - "lineHeight": 1.5, - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "Gg5OV", - "name": "profileActions", - "gap": 8, - "children": [ - { - "id": "MAV5Q", - "type": "ref", - "ref": "yiKZl", - "name": "editBtn", - "descendants": { - "p2JGy": { - "enabled": true, - "iconFontName": "pencil" - }, - "onBtR": { - "content": "Edit Profile" - } - } - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "lD6Qn", - "name": "Personal Info Card", - "width": "fill_container", - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "layout": "vertical", - "gap": 20, - "padding": 28, - "children": [ - { - "type": "frame", - "id": "I2rrd", - "name": "personalHeader", - "width": "fill_container", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "Kd2Wg", - "name": "personalTitle", - "fill": "$--fg-primary", - "content": "Personal Information", - "fontFamily": "DM Sans", - "fontSize": 16, - "fontWeight": "600" - } - ] - }, - { - "type": "text", - "id": "Pad7h", - "name": "personalDesc", - "fill": "$--fg-secondary", - "textGrowth": "fixed-width", - "width": "fill_container", - "content": "This information is shared when you sign in to apps using Certified.", - "lineHeight": 1.5, - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "frame", - "id": "mWFGZ", - "name": "formRow1", - "width": "fill_container", - "gap": 16, - "children": [ - { - "id": "IQ5Sx", - "type": "ref", - "ref": "MuJLu", - "width": "fill_container", - "name": "firstName", - "descendants": { - "Pz34e": { - "content": "First Name" - }, - "2GyAy": { - "content": "Jane" - } - } - }, - { - "id": "qZIYW", - "type": "ref", - "ref": "MuJLu", - "width": "fill_container", - "name": "lastName", - "descendants": { - "Pz34e": { - "content": "Last Name" - }, - "2GyAy": { - "content": "Doe" - } - } - } - ] - }, - { - "id": "tUldR", - "type": "ref", - "ref": "MuJLu", - "width": "fill_container", - "name": "emailField", - "descendants": { - "Pz34e": { - "content": "Email Address" - }, - "2GyAy": { - "content": "jane.doe@email.com" - } - } - }, - { - "id": "EQ6Kf", - "type": "ref", - "ref": "MuJLu", - "width": "fill_container", - "name": "handleField", - "descendants": { - "Pz34e": { - "content": "Handle (DID)" - }, - "2GyAy": { - "content": "did:plc:z72i7hdynmk6r22z27h6tvur" - } - } - }, - { - "type": "frame", - "id": "d95X0", - "name": "saveRow", - "width": "fill_container", - "gap": 12, - "justifyContent": "end", - "children": [ - { - "id": "Flesw", - "type": "ref", - "ref": "BIlhG", - "name": "cancelBtn", - "descendants": { - "ZVLIR": { - "content": "Cancel" - } - } - }, - { - "id": "mj3rT", - "type": "ref", - "ref": "yiKZl", - "name": "saveBtn", - "descendants": { - "onBtR": { - "content": "Save Changes" - } - } - } - ] - } - ] - }, - { - "type": "frame", - "id": "LbdXg", - "name": "Connected Apps Card", - "width": "fill_container", - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "layout": "vertical", - "gap": 16, - "padding": 28, - "children": [ - { - "type": "frame", - "id": "FrCM9", - "name": "appsHeader", - "width": "fill_container", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "g3qqO", - "name": "appsTitle", - "fill": "$--fg-primary", - "content": "Connected Apps", - "fontFamily": "DM Sans", - "fontSize": 16, - "fontWeight": "600" - }, - { - "type": "frame", - "id": "McHGW", - "name": "appsCount", - "fill": "$--bg-muted", - "cornerRadius": "$--radius-pill", - "padding": [ - 4, - 10 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "hzluB", - "name": "appsCountText", - "fill": "$--fg-secondary", - "content": "12 apps", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "500" - } - ] - } - ] - }, - { - "type": "text", - "id": "UXlPf", - "name": "appsDesc", - "fill": "$--fg-secondary", - "textGrowth": "fixed-width", - "width": "fill_container", - "content": "Apps that can access your Certified identity when you sign in.", - "lineHeight": 1.5, - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "frame", - "id": "O2Obj", - "name": "Apps List", - "width": "fill_container", - "layout": "vertical", - "gap": 10, - "children": [ - { - "id": "NcmBq", - "type": "ref", - "ref": "gwsbm", - "width": "fill_container", - "name": "app1", - "descendants": { - "4OFpx": { - "iconFontName": "message-circle" - }, - "QDqbJ": { - "content": "Bluesky" - }, - "csWAF": { - "content": "Decentralized social networking" - }, - "nuPbO": { - "content": "Connected" - } - } - }, - { - "id": "KAH7h", - "type": "ref", - "ref": "gwsbm", - "width": "fill_container", - "name": "app2", - "descendants": { - "4OFpx": { - "iconFontName": "radio" - }, - "QDqbJ": { - "content": "Whitewind" - }, - "csWAF": { - "content": "Long-form publishing on AT Protocol" - }, - "nuPbO": { - "content": "Connected" - } - } - }, - { - "id": "yVLBd", - "type": "ref", - "ref": "gwsbm", - "width": "fill_container", - "name": "app3", - "descendants": { - "4OFpx": { - "iconFontName": "image" - }, - "QDqbJ": { - "content": "Frontpage" - }, - "csWAF": { - "content": "Link aggregator and community discussions" - }, - "nuPbO": { - "content": "Connected" - } - } - }, - { - "id": "4wIjP", - "type": "ref", - "ref": "gwsbm", - "width": "fill_container", - "name": "app4", - "descendants": { - "4OFpx": { - "iconFontName": "zap" - }, - "QDqbJ": { - "content": "Smokesignal" - }, - "csWAF": { - "content": "Decentralized events and meetups" - }, - "nuPbO": { - "content": "Connected" - } - } - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "H2Z5P", - "name": "Right Column", - "width": 340, - "layout": "vertical", - "gap": 24, - "children": [ - { - "type": "frame", - "id": "ZUWjX", - "name": "Sign In Preview", - "width": "fill_container", - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "layout": "vertical", - "gap": 20, - "padding": 28, - "children": [ - { - "type": "text", - "id": "kJKGI", - "name": "signInTitle", - "fill": "$--fg-primary", - "content": "Sign in with Certified", - "fontFamily": "DM Sans", - "fontSize": 16, - "fontWeight": "600" - }, - { - "type": "text", - "id": "VNmbe", - "name": "signInDesc", - "fill": "$--fg-secondary", - "textGrowth": "fixed-width", - "width": "fill_container", - "content": "Use your Certified identity to sign in to any app in the AT Protocol ecosystem. Your data and identity travel with you.", - "lineHeight": 1.5, - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "frame", - "id": "dirw8", - "name": "Button Preview", - "width": "fill_container", - "fill": "$--bg-muted", - "cornerRadius": "$--radius-md", - "layout": "vertical", - "gap": 12, - "padding": 24, - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "kyWtW", - "name": "previewLabel", - "fill": "$--fg-muted", - "content": "Preview", - "fontFamily": "DM Sans", - "fontSize": 11, - "fontWeight": "500", - "letterSpacing": 1 - }, - { - "type": "frame", - "id": "hjjt5", - "name": "signInBtn", - "width": "fill_container", - "height": 46, - "fill": "$--brand-primary", - "cornerRadius": "$--radius-pill", - "gap": 10, - "padding": [ - 12, - 24 - ], - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "IWMpT", - "name": "signInBtnIcon", - "width": 22, - "height": 22, - "fill": "$--fg-on-dark", - "cornerRadius": 6, - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "5Paiz", - "name": "signInBtnCheck", - "width": 14, - "height": 14, - "iconFontName": "shield-check", - "iconFontFamily": "lucide", - "fill": "$--brand-primary" - } - ] - }, - { - "type": "text", - "id": "xO3TN", - "name": "signInBtnText", - "fill": "$--fg-on-dark", - "content": "Sign in with Certified", - "fontFamily": "DM Sans", - "fontSize": 15, - "fontWeight": "500" - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "wd8TU", - "name": "Quick Stats", - "width": "fill_container", - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "layout": "vertical", - "gap": 20, - "padding": 28, - "children": [ - { - "type": "text", - "id": "ky1UD", - "name": "statsTitle", - "fill": "$--fg-primary", - "content": "Identity Overview", - "fontFamily": "DM Sans", - "fontSize": 16, - "fontWeight": "600" - }, - { - "type": "frame", - "id": "Ka3Th", - "name": "statRow1", - "width": "fill_container", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "GDXWq", - "name": "statLabel1", - "fill": "$--fg-secondary", - "content": "Connected Apps", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "yHYd7", - "name": "statVal1", - "fill": "$--fg-primary", - "content": "12", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "lcRJE", - "name": "divider1", - "width": "fill_container", - "height": 1, - "fill": "$--border" - }, - { - "type": "frame", - "id": "hpveq", - "name": "statRow2", - "width": "fill_container", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "t5UA4", - "name": "statLabel2", - "fill": "$--fg-secondary", - "content": "Data Repositories", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "isKdQ", - "name": "statVal2", - "fill": "$--fg-primary", - "content": "3", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "JpLEF", - "name": "divider2", - "width": "fill_container", - "height": 1, - "fill": "$--border" - }, - { - "type": "frame", - "id": "pvRrX", - "name": "statRow3", - "width": "fill_container", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "sXUox", - "name": "statLabel3", - "fill": "$--fg-secondary", - "content": "Sign-ins (30d)", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "ET6j4", - "name": "statVal3", - "fill": "$--fg-primary", - "content": "47", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "rf52B", - "name": "divider3", - "width": "fill_container", - "height": 1, - "fill": "$--border" - }, - { - "type": "frame", - "id": "6QH6v", - "name": "statRow4", - "width": "fill_container", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "twhAK", - "name": "statLabel4", - "fill": "$--fg-secondary", - "content": "Identity Verified", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "frame", - "id": "csbSs", - "name": "verifiedBadge", - "fill": "$--success-bg", - "cornerRadius": "$--radius-pill", - "gap": 4, - "padding": [ - 4, - 10 - ], - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "kjOsY", - "name": "verifiedIcon", - "width": 14, - "height": 14, - "iconFontName": "circle-check", - "iconFontFamily": "lucide", - "fill": "$--success" - }, - { - "type": "text", - "id": "u7M40", - "name": "verifiedText", - "fill": "$--success", - "content": "Verified", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "500" - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "IoliM", - "name": "Recent Activity", - "width": "fill_container", - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "layout": "vertical", - "gap": 16, - "padding": 28, - "children": [ - { - "type": "text", - "id": "AqgU1", - "name": "actTitle", - "fill": "$--fg-primary", - "content": "Recent Activity", - "fontFamily": "DM Sans", - "fontSize": 16, - "fontWeight": "600" - }, - { - "type": "frame", - "id": "GpIGy", - "name": "act1", - "width": "fill_container", - "gap": 12, - "padding": [ - 4, - 0, - 0, - 0 - ], - "children": [ - { - "type": "ellipse", - "id": "mZzU8", - "name": "act1Dot", - "fill": "$--success", - "width": 8, - "height": 8 - }, - { - "type": "frame", - "id": "WESuL", - "name": "act1Info", - "width": "fill_container", - "layout": "vertical", - "gap": 2, - "children": [ - { - "type": "text", - "id": "2MEWt", - "name": "act1Text", - "fill": "$--fg-primary", - "content": "Signed in to Bluesky", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "500" - }, - { - "type": "text", - "id": "Dlrrg", - "name": "act1Time", - "fill": "$--fg-muted", - "content": "2 hours ago", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "type": "frame", - "id": "npGE9", - "name": "actDiv1", - "width": "fill_container", - "height": 1, - "fill": "$--border" - }, - { - "type": "frame", - "id": "IsiUn", - "name": "act2", - "width": "fill_container", - "gap": 12, - "padding": [ - 4, - 0, - 0, - 0 - ], - "children": [ - { - "type": "ellipse", - "id": "uR7bF", - "name": "act2Dot", - "fill": "$--brand-accent", - "width": 8, - "height": 8 - }, - { - "type": "frame", - "id": "nR8dF", - "name": "act2Info", - "width": "fill_container", - "layout": "vertical", - "gap": 2, - "children": [ - { - "type": "text", - "id": "QI0Xv", - "name": "act2Text", - "fill": "$--fg-primary", - "content": "Profile updated", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "500" - }, - { - "type": "text", - "id": "oUdOa", - "name": "act2Time", - "fill": "$--fg-muted", - "content": "Yesterday", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "type": "frame", - "id": "HXipP", - "name": "actDiv2", - "width": "fill_container", - "height": 1, - "fill": "$--border" - }, - { - "type": "frame", - "id": "949wu", - "name": "act3", - "width": "fill_container", - "gap": 12, - "padding": [ - 4, - 0, - 0, - 0 - ], - "children": [ - { - "type": "ellipse", - "id": "4wvWr", - "name": "act3Dot", - "fill": "$--success", - "width": 8, - "height": 8 - }, - { - "type": "frame", - "id": "gyyOf", - "name": "act3Info", - "width": "fill_container", - "layout": "vertical", - "gap": 2, - "children": [ - { - "type": "text", - "id": "kTa3E", - "name": "act3Text", - "fill": "$--fg-primary", - "content": "Connected Whitewind", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "500" - }, - { - "type": "text", - "id": "iL1Wm", - "name": "act3Time", - "fill": "$--fg-muted", - "content": "3 days ago", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "normal" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "yiKZl", - "x": 1600, - "y": 0, - "name": "Component/Button/Primary", - "reusable": true, - "height": 40, - "fill": "$--brand-primary", - "cornerRadius": "$--radius-pill", - "gap": 8, - "padding": [ - 10, - 20 - ], - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "p2JGy", - "name": "btnPIcon", - "enabled": false, - "width": 16, - "height": 16, - "iconFontName": "plus", - "iconFontFamily": "lucide", - "fill": "$--fg-on-dark" - }, - { - "type": "text", - "id": "onBtR", - "name": "btnPLabel", - "fill": "$--fg-on-dark", - "content": "Button", - "fontFamily": "$--font-primary", - "fontSize": 14, - "fontWeight": "500" - } - ] - }, - { - "type": "frame", - "id": "BIlhG", - "x": 1600, - "y": 60, - "name": "Component/Button/Secondary", - "reusable": true, - "height": 40, - "fill": "$--bg-card", - "cornerRadius": "$--radius-pill", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "gap": 8, - "padding": [ - 10, - 20 - ], - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "HCc99", - "name": "btnSIcon", - "enabled": false, - "width": 16, - "height": 16, - "iconFontName": "plus", - "iconFontFamily": "lucide", - "fill": "$--fg-primary" - }, - { - "type": "text", - "id": "ZVLIR", - "name": "btnSLabel", - "fill": "$--fg-primary", - "content": "Button", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "500" - } - ] - }, - { - "type": "frame", - "id": "MuJLu", - "x": 1600, - "y": 120, - "name": "Component/InputGroup", - "reusable": true, - "width": 320, - "layout": "vertical", - "gap": 6, - "children": [ - { - "type": "text", - "id": "Pz34e", - "name": "inputLabel", - "fill": "$--fg-secondary", - "content": "Label", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "500" - }, - { - "type": "frame", - "id": "yvmiR", - "name": "inputField", - "width": "fill_container", - "height": 42, - "fill": "$--bg-input", - "cornerRadius": "$--radius-md", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border-input" - }, - "padding": [ - 0, - 14 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "2GyAy", - "name": "inputText", - "fill": "$--fg-primary", - "content": "Value", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "type": "frame", - "id": "m3saR", - "x": 1600, - "y": 220, - "name": "Component/NavItem/Default", - "reusable": true, - "width": 240, - "height": 42, - "cornerRadius": "$--radius-md", - "gap": 12, - "padding": [ - 10, - 16 - ], - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "5PuVb", - "name": "navIcon", - "width": 20, - "height": 20, - "iconFontName": "user", - "iconFontFamily": "lucide", - "fill": "$--fg-on-dark-muted" - }, - { - "type": "text", - "id": "6f3Km", - "name": "navLabel", - "fill": "$--fg-on-dark-muted", - "content": "Nav Item", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "lQjfT", - "x": 1600, - "y": 280, - "name": "Component/NavItem/Active", - "reusable": true, - "width": 240, - "height": 42, - "fill": "$--bg-sidebar-active", - "cornerRadius": "$--radius-md", - "gap": 12, - "padding": [ - 10, - 16 - ], - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "fylsY", - "name": "navAIcon", - "width": 20, - "height": 20, - "iconFontName": "user", - "iconFontFamily": "lucide", - "fill": "$--fg-on-dark" - }, - { - "type": "text", - "id": "EWTGJ", - "name": "navALabel", - "fill": "$--fg-on-dark", - "content": "Nav Item", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "500" - } - ] - }, - { - "type": "frame", - "id": "gwsbm", - "x": 1600, - "y": 340, - "name": "Component/AppCard", - "reusable": true, - "width": 460, - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "gap": 16, - "padding": 20, - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "GvgcM", - "name": "appIconFrame", - "width": 44, - "height": 44, - "fill": "$--bg-muted", - "cornerRadius": "$--radius-md", - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "4OFpx", - "name": "appIconImg", - "width": 22, - "height": 22, - "iconFontName": "globe", - "iconFontFamily": "lucide", - "fill": "$--brand-teal" - } - ] - }, - { - "type": "frame", - "id": "db1ZB", - "name": "appInfo", - "width": "fill_container", - "layout": "vertical", - "gap": 2, - "children": [ - { - "type": "text", - "id": "QDqbJ", - "name": "appName", - "fill": "$--fg-primary", - "content": "App Name", - "fontFamily": "DM Sans", - "fontSize": 15, - "fontWeight": "600" - }, - { - "type": "text", - "id": "csWAF", - "name": "appDesc", - "fill": "$--fg-secondary", - "content": "Description of the connected app", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "8zGDV", - "name": "appStatus", - "fill": "$--success-bg", - "cornerRadius": "$--radius-pill", - "gap": 6, - "padding": [ - 6, - 12 - ], - "alignItems": "center", - "children": [ - { - "type": "ellipse", - "id": "gwbcM", - "name": "appStatusDot", - "fill": "$--success", - "width": 6, - "height": 6 - }, - { - "type": "text", - "id": "nuPbO", - "name": "appStatusText", - "fill": "$--success", - "content": "Connected", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "500" - } - ] - } - ] - } - ], - "variables": { - "--bg-card": { - "type": "color", - "value": "#FFFFFF" - }, - "--bg-input": { - "type": "color", - "value": "#FFFFFF" - }, - "--bg-muted": { - "type": "color", - "value": "#F0EFEC" - }, - "--bg-page": { - "type": "color", - "value": "#F6F5F2" - }, - "--bg-sidebar": { - "type": "color", - "value": [ - { - "value": "#1A3A3A" - }, - { - "value": "#0F2544" - } - ] - }, - "--bg-sidebar-active": { - "type": "color", - "value": [ - { - "value": "#2D6363" - }, - { - "value": "#1A3560" - } - ] - }, - "--bg-sidebar-hover": { - "type": "color", - "value": [ - { - "value": "#245050" - }, - { - "value": "#152D4D" - } - ] - }, - "--border": { - "type": "color", - "value": "#E5E4E1" - }, - "--border-focus": { - "type": "color", - "value": [ - { - "value": "#1A3A3A" - }, - { - "value": "#0F2544" - } - ] - }, - "--border-input": { - "type": "color", - "value": "#D5D4D1" - }, - "--brand-accent": { - "type": "color", - "value": [ - { - "value": "#3D8B8B" - }, - { - "value": "#60A1E2" - } - ] - }, - "--brand-primary": { - "type": "color", - "value": [ - { - "value": "#1A3A3A" - }, - { - "value": "#0F2544" - } - ] - }, - "--brand-teal": { - "type": "color", - "value": "#2D6363" - }, - "--error": { - "type": "color", - "value": "#D44040" - }, - "--error-bg": { - "type": "color", - "value": "#FDE8E8" - }, - "--fg-muted": { - "type": "color", - "value": "#9B9B9B" - }, - "--fg-on-dark": { - "type": "color", - "value": "#FFFFFF" - }, - "--fg-on-dark-muted": { - "type": "color", - "value": "#A3C4C4" - }, - "--fg-primary": { - "type": "color", - "value": "#1A1A1A" - }, - "--fg-secondary": { - "type": "color", - "value": "#6B6B6B" - }, - "--font-display": { - "type": "string", - "value": "DM Serif Display" - }, - "--font-primary": { - "type": "string", - "value": "DM Sans" - }, - "--radius-lg": { - "type": "number", - "value": 14 - }, - "--radius-md": { - "type": "number", - "value": 10 - }, - "--radius-pill": { - "type": "number", - "value": 100 - }, - "--radius-sm": { - "type": "number", - "value": 6 - }, - "--radius-xl": { - "type": "number", - "value": 20 - }, - "--success": { - "type": "color", - "value": "#2D8B57" - }, - "--success-bg": { - "type": "color", - "value": "#E8F5EE" - }, - "--warning": { - "type": "color", - "value": "#D4830A" - }, - "--warning-bg": { - "type": "color", - "value": "#FFF8E8" - }, - "--certified-dark": { - "type": "color", - "value": "#0F2544" - }, - "--certified-light": { - "type": "color", - "value": "#60A1E2" - } - } -} \ No newline at end of file diff --git a/certified.pen b/certified.pen deleted file mode 100644 index 81814bf7..00000000 --- a/certified.pen +++ /dev/null @@ -1,1870 +0,0 @@ -{ - "version": "2.8", - "children": [ - { - "type": "frame", - "id": "fRwnH", - "x": 0, - "y": 0, - "name": "Certified - Profile Manager", - "clip": true, - "width": 1440, - "height": 1260, - "fill": "$--bg-page", - "children": [ - { - "type": "frame", - "id": "3mmi2", - "name": "Sidebar", - "width": 280, - "height": "fill_container", - "fill": "$--bg-sidebar", - "layout": "vertical", - "padding": [ - 24, - 20 - ], - "children": [ - { - "type": "frame", - "id": "VXmP3", - "name": "Logo", - "width": "fill_container", - "gap": 10, - "padding": [ - 0, - 0, - 24, - 0 - ], - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "mGAhp", - "name": "Wordmark", - "gap": 8, - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "lWpaE", - "name": "checkIcon", - "width": 24, - "height": 24, - "iconFontName": "shield-check", - "iconFontFamily": "lucide", - "fill": "#60A1E2" - }, - { - "type": "text", - "id": "ANith", - "name": "wordmarkText", - "fill": "#FFFFFF", - "content": "Certified", - "fontFamily": "DM Serif Display", - "fontSize": 20, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "type": "frame", - "id": "y6lkl", - "name": "Nav Section", - "width": "fill_container", - "height": "fill_container", - "layout": "vertical", - "gap": 2, - "children": [ - { - "type": "text", - "id": "1P4hz", - "name": "navSectionLabel", - "fill": "$--fg-on-dark-muted", - "content": "ACCOUNT", - "fontFamily": "DM Sans", - "fontSize": 11, - "fontWeight": "600", - "letterSpacing": 1.2 - }, - { - "id": "nPHLL", - "type": "ref", - "ref": "lQjfT", - "width": "fill_container", - "name": "navProfile", - "descendants": { - "fylsY": { - "iconFontName": "user" - }, - "EWTGJ": { - "content": "Profile" - } - } - }, - { - "id": "pxZmZ", - "type": "ref", - "ref": "m3saR", - "width": "fill_container", - "name": "navSecurity", - "descendants": { - "5PuVb": { - "iconFontName": "shield" - }, - "6f3Km": { - "content": "Security" - } - } - }, - { - "id": "H4inJ", - "type": "ref", - "ref": "m3saR", - "width": "fill_container", - "name": "navApps", - "descendants": { - "5PuVb": { - "iconFontName": "layout-grid" - }, - "6f3Km": { - "content": "Connected Apps" - } - } - }, - { - "id": "2G7D7", - "type": "ref", - "ref": "m3saR", - "width": "fill_container", - "name": "navData", - "descendants": { - "5PuVb": { - "iconFontName": "database" - }, - "6f3Km": { - "content": "Data & Privacy" - } - } - }, - { - "id": "cMBY8", - "type": "ref", - "ref": "m3saR", - "width": "fill_container", - "name": "navIdentity", - "descendants": { - "5PuVb": { - "iconFontName": "fingerprint" - }, - "6f3Km": { - "content": "Identity" - } - } - } - ] - }, - { - "type": "frame", - "id": "fkSmb", - "name": "User Profile", - "width": "fill_container", - "stroke": { - "align": "inside", - "thickness": { - "top": 1 - }, - "fill": "#1A3560" - }, - "gap": 12, - "padding": [ - 16, - 0, - 0, - 0 - ], - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "WhGHs", - "name": "avatar", - "width": 36, - "height": 36, - "fill": "$--brand-accent", - "cornerRadius": 100, - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "V40vK", - "name": "avatarText", - "fill": "$--fg-on-dark", - "content": "JD", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "GZZpM", - "name": "userInfo", - "width": "fill_container", - "layout": "vertical", - "gap": 1, - "children": [ - { - "type": "text", - "id": "fE0mh", - "name": "userName", - "fill": "$--fg-on-dark", - "content": "Jane Doe", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "500" - }, - { - "type": "text", - "id": "NDp7D", - "name": "userHandle", - "fill": "$--fg-on-dark-muted", - "content": "@jane.certified.id", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "normal" - } - ] - }, - { - "type": "icon_font", - "id": "mkRdp", - "name": "logoutIcon", - "width": 18, - "height": 18, - "iconFontName": "log-out", - "iconFontFamily": "lucide", - "fill": "$--fg-on-dark-muted" - } - ] - } - ] - }, - { - "type": "frame", - "id": "kQlV6", - "name": "Main Content", - "width": "fill_container", - "height": "fill_container", - "layout": "vertical", - "children": [ - { - "type": "frame", - "id": "RmZzG", - "name": "Header Bar", - "width": "fill_container", - "height": 64, - "fill": "$--bg-card", - "stroke": { - "align": "inside", - "thickness": { - "bottom": 1 - }, - "fill": "$--border" - }, - "padding": [ - 0, - 32 - ], - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "O3RPp", - "name": "headerLeft", - "gap": 8, - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "x1mSd", - "name": "headerTitle", - "fill": "$--fg-primary", - "content": "Profile", - "fontFamily": "DM Serif Display", - "fontSize": 22, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "8FipO", - "name": "headerRight", - "gap": 12, - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "pilTt", - "name": "searchBox", - "width": 220, - "height": 36, - "fill": "$--bg-muted", - "cornerRadius": "$--radius-pill", - "gap": 8, - "padding": [ - 8, - 14 - ], - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "9BdN6", - "name": "searchIcon", - "width": 16, - "height": 16, - "iconFontName": "search", - "iconFontFamily": "lucide", - "fill": "$--fg-muted" - }, - { - "type": "text", - "id": "1b4eI", - "name": "searchText", - "fill": "$--fg-muted", - "content": "Search settings...", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "x6ut8", - "name": "bellIcon", - "width": 36, - "height": 36, - "cornerRadius": 100, - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "o0yfR", - "name": "bellImg", - "width": 20, - "height": 20, - "iconFontName": "bell", - "iconFontFamily": "lucide", - "fill": "$--fg-secondary" - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "NOjop", - "name": "Content Area", - "width": "fill_container", - "height": "fill_container", - "gap": 32, - "padding": 32, - "children": [ - { - "type": "frame", - "id": "PHqKs", - "name": "Left Column", - "width": "fill_container", - "layout": "vertical", - "gap": 24, - "children": [ - { - "type": "frame", - "id": "V16e2", - "name": "Profile Card", - "width": "fill_container", - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "layout": "vertical", - "gap": 24, - "padding": 28, - "children": [ - { - "type": "frame", - "id": "YhzFQ", - "name": "Profile Header", - "width": "fill_container", - "gap": 20, - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "m4d3I", - "name": "avatarLarge", - "width": 72, - "height": 72, - "fill": "$--brand-accent", - "cornerRadius": 100, - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "QQnwD", - "name": "avatarInit", - "fill": "$--fg-on-dark", - "content": "JD", - "fontFamily": "DM Sans", - "fontSize": 24, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "K6O4W", - "name": "profileInfo", - "width": "fill_container", - "layout": "vertical", - "gap": 4, - "children": [ - { - "type": "text", - "id": "LzrY8", - "name": "profileName", - "fill": "$--fg-primary", - "content": "Jane Doe", - "fontFamily": "DM Serif Display", - "fontSize": 24, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "Jk9dy", - "name": "profileHandle", - "fill": "$--fg-secondary", - "content": "@jane.certified.id", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "6OjPz", - "name": "profileBio", - "fill": "$--fg-secondary", - "textGrowth": "fixed-width", - "width": "fill_container", - "content": "Product designer and open web advocate. Building for the decentralized future.", - "lineHeight": 1.5, - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "Gg5OV", - "name": "profileActions", - "gap": 8, - "children": [ - { - "id": "MAV5Q", - "type": "ref", - "ref": "yiKZl", - "name": "editBtn", - "descendants": { - "p2JGy": { - "enabled": true, - "iconFontName": "pencil" - }, - "onBtR": { - "content": "Edit Profile" - } - } - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "lD6Qn", - "name": "Personal Info Card", - "width": "fill_container", - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "layout": "vertical", - "gap": 20, - "padding": 28, - "children": [ - { - "type": "frame", - "id": "I2rrd", - "name": "personalHeader", - "width": "fill_container", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "Kd2Wg", - "name": "personalTitle", - "fill": "$--fg-primary", - "content": "Personal Information", - "fontFamily": "DM Sans", - "fontSize": 16, - "fontWeight": "600" - } - ] - }, - { - "type": "text", - "id": "Pad7h", - "name": "personalDesc", - "fill": "$--fg-secondary", - "textGrowth": "fixed-width", - "width": "fill_container", - "content": "This information is shared when you sign in to apps using Certified.", - "lineHeight": 1.5, - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "frame", - "id": "mWFGZ", - "name": "formRow1", - "width": "fill_container", - "gap": 16, - "children": [ - { - "id": "IQ5Sx", - "type": "ref", - "ref": "MuJLu", - "width": "fill_container", - "name": "firstName", - "descendants": { - "Pz34e": { - "content": "First Name" - }, - "2GyAy": { - "content": "Jane" - } - } - }, - { - "id": "qZIYW", - "type": "ref", - "ref": "MuJLu", - "width": "fill_container", - "name": "lastName", - "descendants": { - "Pz34e": { - "content": "Last Name" - }, - "2GyAy": { - "content": "Doe" - } - } - } - ] - }, - { - "id": "tUldR", - "type": "ref", - "ref": "MuJLu", - "width": "fill_container", - "name": "emailField", - "descendants": { - "Pz34e": { - "content": "Email Address" - }, - "2GyAy": { - "content": "jane.doe@email.com" - } - } - }, - { - "id": "EQ6Kf", - "type": "ref", - "ref": "MuJLu", - "width": "fill_container", - "name": "handleField", - "descendants": { - "Pz34e": { - "content": "Handle (DID)" - }, - "2GyAy": { - "content": "did:plc:z72i7hdynmk6r22z27h6tvur" - } - } - }, - { - "type": "frame", - "id": "d95X0", - "name": "saveRow", - "width": "fill_container", - "gap": 12, - "justifyContent": "end", - "children": [ - { - "id": "Flesw", - "type": "ref", - "ref": "BIlhG", - "name": "cancelBtn", - "descendants": { - "ZVLIR": { - "content": "Cancel" - } - } - }, - { - "id": "mj3rT", - "type": "ref", - "ref": "yiKZl", - "name": "saveBtn", - "descendants": { - "onBtR": { - "content": "Save Changes" - } - } - } - ] - } - ] - }, - { - "type": "frame", - "id": "LbdXg", - "name": "Connected Apps Card", - "width": "fill_container", - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "layout": "vertical", - "gap": 16, - "padding": 28, - "children": [ - { - "type": "frame", - "id": "FrCM9", - "name": "appsHeader", - "width": "fill_container", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "g3qqO", - "name": "appsTitle", - "fill": "$--fg-primary", - "content": "Connected Apps", - "fontFamily": "DM Sans", - "fontSize": 16, - "fontWeight": "600" - }, - { - "type": "frame", - "id": "McHGW", - "name": "appsCount", - "fill": "$--bg-muted", - "cornerRadius": "$--radius-pill", - "padding": [ - 4, - 10 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "hzluB", - "name": "appsCountText", - "fill": "$--fg-secondary", - "content": "12 apps", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "500" - } - ] - } - ] - }, - { - "type": "text", - "id": "UXlPf", - "name": "appsDesc", - "fill": "$--fg-secondary", - "textGrowth": "fixed-width", - "width": "fill_container", - "content": "Apps that can access your Certified identity when you sign in.", - "lineHeight": 1.5, - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "frame", - "id": "O2Obj", - "name": "Apps List", - "width": "fill_container", - "layout": "vertical", - "gap": 10, - "children": [ - { - "id": "NcmBq", - "type": "ref", - "ref": "gwsbm", - "width": "fill_container", - "name": "app1", - "descendants": { - "4OFpx": { - "iconFontName": "message-circle" - }, - "QDqbJ": { - "content": "Bluesky" - }, - "csWAF": { - "content": "Decentralized social networking" - }, - "nuPbO": { - "content": "Connected" - } - } - }, - { - "id": "KAH7h", - "type": "ref", - "ref": "gwsbm", - "width": "fill_container", - "name": "app2", - "descendants": { - "4OFpx": { - "iconFontName": "radio" - }, - "QDqbJ": { - "content": "Whitewind" - }, - "csWAF": { - "content": "Long-form publishing on AT Protocol" - }, - "nuPbO": { - "content": "Connected" - } - } - }, - { - "id": "yVLBd", - "type": "ref", - "ref": "gwsbm", - "width": "fill_container", - "name": "app3", - "descendants": { - "4OFpx": { - "iconFontName": "image" - }, - "QDqbJ": { - "content": "Frontpage" - }, - "csWAF": { - "content": "Link aggregator and community discussions" - }, - "nuPbO": { - "content": "Connected" - } - } - }, - { - "id": "4wIjP", - "type": "ref", - "ref": "gwsbm", - "width": "fill_container", - "name": "app4", - "descendants": { - "4OFpx": { - "iconFontName": "zap" - }, - "QDqbJ": { - "content": "Smokesignal" - }, - "csWAF": { - "content": "Decentralized events and meetups" - }, - "nuPbO": { - "content": "Connected" - } - } - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "H2Z5P", - "name": "Right Column", - "width": 340, - "layout": "vertical", - "gap": 24, - "children": [ - { - "type": "frame", - "id": "ZUWjX", - "name": "Sign In Preview", - "width": "fill_container", - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "layout": "vertical", - "gap": 20, - "padding": 28, - "children": [ - { - "type": "text", - "id": "kJKGI", - "name": "signInTitle", - "fill": "$--fg-primary", - "content": "Sign in with Certified", - "fontFamily": "DM Sans", - "fontSize": 16, - "fontWeight": "600" - }, - { - "type": "text", - "id": "VNmbe", - "name": "signInDesc", - "fill": "$--fg-secondary", - "textGrowth": "fixed-width", - "width": "fill_container", - "content": "Use your Certified identity to sign in to any app in the AT Protocol ecosystem. Your data and identity travel with you.", - "lineHeight": 1.5, - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "normal" - }, - { - "type": "frame", - "id": "dirw8", - "name": "Button Preview", - "width": "fill_container", - "fill": "$--bg-muted", - "cornerRadius": "$--radius-md", - "layout": "vertical", - "gap": 12, - "padding": 24, - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "kyWtW", - "name": "previewLabel", - "fill": "$--fg-muted", - "content": "Preview", - "fontFamily": "DM Sans", - "fontSize": 11, - "fontWeight": "500", - "letterSpacing": 1 - }, - { - "type": "frame", - "id": "hjjt5", - "name": "signInBtn", - "width": "fill_container", - "height": 46, - "fill": "$--brand-primary", - "cornerRadius": "$--radius-pill", - "gap": 10, - "padding": [ - 12, - 24 - ], - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "IWMpT", - "name": "signInBtnIcon", - "width": 22, - "height": 22, - "fill": "$--fg-on-dark", - "cornerRadius": 6, - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "5Paiz", - "name": "signInBtnCheck", - "width": 14, - "height": 14, - "iconFontName": "shield-check", - "iconFontFamily": "lucide", - "fill": "$--brand-primary" - } - ] - }, - { - "type": "text", - "id": "xO3TN", - "name": "signInBtnText", - "fill": "$--fg-on-dark", - "content": "Sign in with Certified", - "fontFamily": "DM Sans", - "fontSize": 15, - "fontWeight": "500" - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "wd8TU", - "name": "Quick Stats", - "width": "fill_container", - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "layout": "vertical", - "gap": 20, - "padding": 28, - "children": [ - { - "type": "text", - "id": "ky1UD", - "name": "statsTitle", - "fill": "$--fg-primary", - "content": "Identity Overview", - "fontFamily": "DM Sans", - "fontSize": 16, - "fontWeight": "600" - }, - { - "type": "frame", - "id": "Ka3Th", - "name": "statRow1", - "width": "fill_container", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "GDXWq", - "name": "statLabel1", - "fill": "$--fg-secondary", - "content": "Connected Apps", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "yHYd7", - "name": "statVal1", - "fill": "$--fg-primary", - "content": "12", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "lcRJE", - "name": "divider1", - "width": "fill_container", - "height": 1, - "fill": "$--border" - }, - { - "type": "frame", - "id": "hpveq", - "name": "statRow2", - "width": "fill_container", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "t5UA4", - "name": "statLabel2", - "fill": "$--fg-secondary", - "content": "Data Repositories", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "isKdQ", - "name": "statVal2", - "fill": "$--fg-primary", - "content": "3", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "JpLEF", - "name": "divider2", - "width": "fill_container", - "height": 1, - "fill": "$--border" - }, - { - "type": "frame", - "id": "pvRrX", - "name": "statRow3", - "width": "fill_container", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "sXUox", - "name": "statLabel3", - "fill": "$--fg-secondary", - "content": "Sign-ins (30d)", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "text", - "id": "ET6j4", - "name": "statVal3", - "fill": "$--fg-primary", - "content": "47", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "600" - } - ] - }, - { - "type": "frame", - "id": "rf52B", - "name": "divider3", - "width": "fill_container", - "height": 1, - "fill": "$--border" - }, - { - "type": "frame", - "id": "6QH6v", - "name": "statRow4", - "width": "fill_container", - "justifyContent": "space_between", - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "twhAK", - "name": "statLabel4", - "fill": "$--fg-secondary", - "content": "Identity Verified", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - }, - { - "type": "frame", - "id": "csbSs", - "name": "verifiedBadge", - "fill": "$--success-bg", - "cornerRadius": "$--radius-pill", - "gap": 4, - "padding": [ - 4, - 10 - ], - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "kjOsY", - "name": "verifiedIcon", - "width": 14, - "height": 14, - "iconFontName": "circle-check", - "iconFontFamily": "lucide", - "fill": "$--success" - }, - { - "type": "text", - "id": "u7M40", - "name": "verifiedText", - "fill": "$--success", - "content": "Verified", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "500" - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "IoliM", - "name": "Recent Activity", - "width": "fill_container", - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "layout": "vertical", - "gap": 16, - "padding": 28, - "children": [ - { - "type": "text", - "id": "AqgU1", - "name": "actTitle", - "fill": "$--fg-primary", - "content": "Recent Activity", - "fontFamily": "DM Sans", - "fontSize": 16, - "fontWeight": "600" - }, - { - "type": "frame", - "id": "GpIGy", - "name": "act1", - "width": "fill_container", - "gap": 12, - "padding": [ - 4, - 0, - 0, - 0 - ], - "children": [ - { - "type": "ellipse", - "id": "mZzU8", - "name": "act1Dot", - "fill": "$--success", - "width": 8, - "height": 8 - }, - { - "type": "frame", - "id": "WESuL", - "name": "act1Info", - "width": "fill_container", - "layout": "vertical", - "gap": 2, - "children": [ - { - "type": "text", - "id": "2MEWt", - "name": "act1Text", - "fill": "$--fg-primary", - "content": "Signed in to Bluesky", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "500" - }, - { - "type": "text", - "id": "Dlrrg", - "name": "act1Time", - "fill": "$--fg-muted", - "content": "2 hours ago", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "type": "frame", - "id": "npGE9", - "name": "actDiv1", - "width": "fill_container", - "height": 1, - "fill": "$--border" - }, - { - "type": "frame", - "id": "IsiUn", - "name": "act2", - "width": "fill_container", - "gap": 12, - "padding": [ - 4, - 0, - 0, - 0 - ], - "children": [ - { - "type": "ellipse", - "id": "uR7bF", - "name": "act2Dot", - "fill": "$--brand-accent", - "width": 8, - "height": 8 - }, - { - "type": "frame", - "id": "nR8dF", - "name": "act2Info", - "width": "fill_container", - "layout": "vertical", - "gap": 2, - "children": [ - { - "type": "text", - "id": "QI0Xv", - "name": "act2Text", - "fill": "$--fg-primary", - "content": "Profile updated", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "500" - }, - { - "type": "text", - "id": "oUdOa", - "name": "act2Time", - "fill": "$--fg-muted", - "content": "Yesterday", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "type": "frame", - "id": "HXipP", - "name": "actDiv2", - "width": "fill_container", - "height": 1, - "fill": "$--border" - }, - { - "type": "frame", - "id": "949wu", - "name": "act3", - "width": "fill_container", - "gap": 12, - "padding": [ - 4, - 0, - 0, - 0 - ], - "children": [ - { - "type": "ellipse", - "id": "4wvWr", - "name": "act3Dot", - "fill": "$--success", - "width": 8, - "height": 8 - }, - { - "type": "frame", - "id": "gyyOf", - "name": "act3Info", - "width": "fill_container", - "layout": "vertical", - "gap": 2, - "children": [ - { - "type": "text", - "id": "kTa3E", - "name": "act3Text", - "fill": "$--fg-primary", - "content": "Connected Whitewind", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "500" - }, - { - "type": "text", - "id": "iL1Wm", - "name": "act3Time", - "fill": "$--fg-muted", - "content": "3 days ago", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "normal" - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - } - ] - }, - { - "type": "frame", - "id": "yiKZl", - "x": 1600, - "y": 0, - "name": "Component/Button/Primary", - "reusable": true, - "height": 40, - "fill": "$--brand-primary", - "cornerRadius": "$--radius-pill", - "gap": 8, - "padding": [ - 10, - 20 - ], - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "p2JGy", - "name": "btnPIcon", - "enabled": false, - "width": 16, - "height": 16, - "iconFontName": "plus", - "iconFontFamily": "lucide", - "fill": "$--fg-on-dark" - }, - { - "type": "text", - "id": "onBtR", - "name": "btnPLabel", - "fill": "$--fg-on-dark", - "content": "Button", - "fontFamily": "$--font-primary", - "fontSize": 14, - "fontWeight": "500" - } - ] - }, - { - "type": "frame", - "id": "BIlhG", - "x": 1600, - "y": 60, - "name": "Component/Button/Secondary", - "reusable": true, - "height": 40, - "fill": "$--bg-card", - "cornerRadius": "$--radius-pill", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "gap": 8, - "padding": [ - 10, - 20 - ], - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "HCc99", - "name": "btnSIcon", - "enabled": false, - "width": 16, - "height": 16, - "iconFontName": "plus", - "iconFontFamily": "lucide", - "fill": "$--fg-primary" - }, - { - "type": "text", - "id": "ZVLIR", - "name": "btnSLabel", - "fill": "$--fg-primary", - "content": "Button", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "500" - } - ] - }, - { - "type": "frame", - "id": "MuJLu", - "x": 1600, - "y": 120, - "name": "Component/InputGroup", - "reusable": true, - "width": 320, - "layout": "vertical", - "gap": 6, - "children": [ - { - "type": "text", - "id": "Pz34e", - "name": "inputLabel", - "fill": "$--fg-secondary", - "content": "Label", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "500" - }, - { - "type": "frame", - "id": "yvmiR", - "name": "inputField", - "width": "fill_container", - "height": 42, - "fill": "$--bg-input", - "cornerRadius": "$--radius-md", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border-input" - }, - "padding": [ - 0, - 14 - ], - "alignItems": "center", - "children": [ - { - "type": "text", - "id": "2GyAy", - "name": "inputText", - "fill": "$--fg-primary", - "content": "Value", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - } - ] - }, - { - "type": "frame", - "id": "m3saR", - "x": 1600, - "y": 220, - "name": "Component/NavItem/Default", - "reusable": true, - "width": 240, - "height": 42, - "cornerRadius": "$--radius-md", - "gap": 12, - "padding": [ - 10, - 16 - ], - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "5PuVb", - "name": "navIcon", - "width": 20, - "height": 20, - "iconFontName": "user", - "iconFontFamily": "lucide", - "fill": "$--fg-on-dark-muted" - }, - { - "type": "text", - "id": "6f3Km", - "name": "navLabel", - "fill": "$--fg-on-dark-muted", - "content": "Nav Item", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "lQjfT", - "x": 1600, - "y": 280, - "name": "Component/NavItem/Active", - "reusable": true, - "width": 240, - "height": 42, - "fill": "$--bg-sidebar-active", - "cornerRadius": "$--radius-md", - "gap": 12, - "padding": [ - 10, - 16 - ], - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "fylsY", - "name": "navAIcon", - "width": 20, - "height": 20, - "iconFontName": "user", - "iconFontFamily": "lucide", - "fill": "$--fg-on-dark" - }, - { - "type": "text", - "id": "EWTGJ", - "name": "navALabel", - "fill": "$--fg-on-dark", - "content": "Nav Item", - "fontFamily": "DM Sans", - "fontSize": 14, - "fontWeight": "500" - } - ] - }, - { - "type": "frame", - "id": "gwsbm", - "x": 1600, - "y": 340, - "name": "Component/AppCard", - "reusable": true, - "width": 460, - "fill": "$--bg-card", - "cornerRadius": "$--radius-lg", - "stroke": { - "align": "inside", - "thickness": 1, - "fill": "$--border" - }, - "gap": 16, - "padding": 20, - "alignItems": "center", - "children": [ - { - "type": "frame", - "id": "GvgcM", - "name": "appIconFrame", - "width": 44, - "height": 44, - "fill": "$--bg-muted", - "cornerRadius": "$--radius-md", - "justifyContent": "center", - "alignItems": "center", - "children": [ - { - "type": "icon_font", - "id": "4OFpx", - "name": "appIconImg", - "width": 22, - "height": 22, - "iconFontName": "globe", - "iconFontFamily": "lucide", - "fill": "$--brand-teal" - } - ] - }, - { - "type": "frame", - "id": "db1ZB", - "name": "appInfo", - "width": "fill_container", - "layout": "vertical", - "gap": 2, - "children": [ - { - "type": "text", - "id": "QDqbJ", - "name": "appName", - "fill": "$--fg-primary", - "content": "App Name", - "fontFamily": "DM Sans", - "fontSize": 15, - "fontWeight": "600" - }, - { - "type": "text", - "id": "csWAF", - "name": "appDesc", - "fill": "$--fg-secondary", - "content": "Description of the connected app", - "fontFamily": "DM Sans", - "fontSize": 13, - "fontWeight": "normal" - } - ] - }, - { - "type": "frame", - "id": "8zGDV", - "name": "appStatus", - "fill": "$--success-bg", - "cornerRadius": "$--radius-pill", - "gap": 6, - "padding": [ - 6, - 12 - ], - "alignItems": "center", - "children": [ - { - "type": "ellipse", - "id": "gwbcM", - "name": "appStatusDot", - "fill": "$--success", - "width": 6, - "height": 6 - }, - { - "type": "text", - "id": "nuPbO", - "name": "appStatusText", - "fill": "$--success", - "content": "Connected", - "fontFamily": "DM Sans", - "fontSize": 12, - "fontWeight": "500" - } - ] - } - ] - } - ], - "variables": { - "--bg-card": { - "type": "color", - "value": "#FFFFFF" - }, - "--bg-input": { - "type": "color", - "value": "#FFFFFF" - }, - "--bg-muted": { - "type": "color", - "value": "#F0EFEC" - }, - "--bg-page": { - "type": "color", - "value": "#F6F5F2" - }, - "--bg-sidebar": { - "type": "color", - "value": [ - { - "value": "#1A3A3A" - }, - { - "value": "#0F2544" - } - ] - }, - "--bg-sidebar-active": { - "type": "color", - "value": [ - { - "value": "#2D6363" - }, - { - "value": "#1A3560" - } - ] - }, - "--bg-sidebar-hover": { - "type": "color", - "value": [ - { - "value": "#245050" - }, - { - "value": "#152D4D" - } - ] - }, - "--border": { - "type": "color", - "value": "#E5E4E1" - }, - "--border-focus": { - "type": "color", - "value": [ - { - "value": "#1A3A3A" - }, - { - "value": "#0F2544" - } - ] - }, - "--border-input": { - "type": "color", - "value": "#D5D4D1" - }, - "--brand-accent": { - "type": "color", - "value": [ - { - "value": "#3D8B8B" - }, - { - "value": "#60A1E2" - } - ] - }, - "--brand-primary": { - "type": "color", - "value": [ - { - "value": "#1A3A3A" - }, - { - "value": "#0F2544" - } - ] - }, - "--brand-teal": { - "type": "color", - "value": "#2D6363" - }, - "--error": { - "type": "color", - "value": "#D44040" - }, - "--error-bg": { - "type": "color", - "value": "#FDE8E8" - }, - "--fg-muted": { - "type": "color", - "value": "#9B9B9B" - }, - "--fg-on-dark": { - "type": "color", - "value": "#FFFFFF" - }, - "--fg-on-dark-muted": { - "type": "color", - "value": "#A3C4C4" - }, - "--fg-primary": { - "type": "color", - "value": "#1A1A1A" - }, - "--fg-secondary": { - "type": "color", - "value": "#6B6B6B" - }, - "--font-display": { - "type": "string", - "value": "DM Serif Display" - }, - "--font-primary": { - "type": "string", - "value": "DM Sans" - }, - "--radius-lg": { - "type": "number", - "value": 14 - }, - "--radius-md": { - "type": "number", - "value": 10 - }, - "--radius-pill": { - "type": "number", - "value": 100 - }, - "--radius-sm": { - "type": "number", - "value": 6 - }, - "--radius-xl": { - "type": "number", - "value": 20 - }, - "--success": { - "type": "color", - "value": "#2D8B57" - }, - "--success-bg": { - "type": "color", - "value": "#E8F5EE" - }, - "--warning": { - "type": "color", - "value": "#D4830A" - }, - "--warning-bg": { - "type": "color", - "value": "#FFF8E8" - }, - "--certified-dark": { - "type": "color", - "value": "#0F2544" - }, - "--certified-light": { - "type": "color", - "value": "#60A1E2" - } - } -} \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 364eb096..d87fa422 100644 --- a/next.config.ts +++ b/next.config.ts @@ -22,6 +22,10 @@ const nextConfig: NextConfig = { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()", }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, ], }, ]; diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index e5956fbe..43264aa7 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -12,6 +12,7 @@ export const metadata: Metadata = { "Certified is a passwordless identity platform built on AT Protocol, operated by the Hypercerts Foundation.", url: "https://certified.app/about", type: "website", + images: [{ url: "/assets/certified-hero-1200x630.png", width: 1200, height: 630, alt: "Certified — One account, any app" }], }, }; diff --git a/src/app/api/auth/callback-handler/route.ts b/src/app/api/auth/callback-handler/route.ts index 98332e27..dfd8a3a2 100644 --- a/src/app/api/auth/callback-handler/route.ts +++ b/src/app/api/auth/callback-handler/route.ts @@ -18,7 +18,7 @@ export async function GET(request: NextRequest) { const { session } = await client.callback(params) // Invalidate any existing session to prevent session fixation - await deleteSession().catch(() => {}) + await deleteSession().catch((err) => console.error("[Auth] Old session cleanup failed:", err)) await createSession(session.did) // Best-effort: ensure profile records exist (don't fail sign-in if this errors) diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index f052695e..de72c08e 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server" import { getOAuthClient, PDS_URL } from "@/lib/auth/oauth-client" import { checkCsrf } from "@/lib/auth/csrf" +import { sanitizeEmail, sanitizeHandle } from "@/lib/utils/sanitize" export async function POST(request: NextRequest) { const csrfError = checkCsrf(request) @@ -18,9 +19,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Invalid input or mode" }, { status: 400 }) } - // Strip invisible Unicode characters and whitespace that can sneak in via clipboard paste - const stripped = rawInput.replace(/[\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF\u00AD\u034F\u061C\u180E\s]/g, '') - const input = mode === "handle" ? stripped.replace(/^@/, '') : stripped.toLowerCase() + const input = mode === "handle" ? sanitizeHandle(rawInput) : sanitizeEmail(rawInput) const client = await getOAuthClient() @@ -53,10 +52,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Invalid mode" }, { status: 400 }) } catch (err) { - const raw = err instanceof Error ? err.message : "" - const message = raw.includes("Invalid") || raw.includes("invalid") - ? raw - : "Authentication failed" - return NextResponse.json({ error: message }, { status: 400 }) + console.error("[Auth] Login error:", err) + return NextResponse.json({ error: "Authentication failed" }, { status: 500 }) } } diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index f95bd032..89dc2925 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -21,5 +21,5 @@ export async function POST(request: NextRequest) { await deleteSession() - return NextResponse.json({ ok: true }) + return NextResponse.json({ success: true }) } diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts index 409ccd03..289634fb 100644 --- a/src/app/api/feedback/route.ts +++ b/src/app/api/feedback/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { Resend } from "resend"; +import { checkCsrf } from "@/lib/auth/csrf"; +import { stripInvisible } from "@/lib/utils/sanitize"; if (!process.env.RESEND_API_KEY) { console.warn("RESEND_API_KEY is not set — feedback emails will fail"); @@ -10,26 +12,22 @@ const SUPPORT_EMAIL = "support@hypercerts.org"; const FROM_EMAIL = process.env.RESEND_FROM_EMAIL || "Certified "; export async function POST(req: NextRequest) { + const csrfError = checkCsrf(req); + if (csrfError) return csrfError; + + let body: unknown; try { - // CSRF origin check - const origin = req.headers.get("origin"); - const host = req.headers.get("host"); - if (!origin || !host) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - try { - const originHostname = new URL(origin).hostname; - const hostHostname = host.split(":")[0]; - if (originHostname !== hostHostname) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - } catch { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } - const { message, email } = await req.json(); + try { + const b = body as { message?: unknown; email?: unknown }; + const message = typeof b.message === "string" ? stripInvisible(b.message) : ""; + const email = typeof b.email === "string" ? stripInvisible(b.email) : ""; - if (!message || typeof message !== "string" || !message.trim()) { + if (!message) { return NextResponse.json({ error: "Message is required" }, { status: 400 }); } diff --git a/src/app/api/organizations/[groupDid]/audit/route.ts b/src/app/api/groups/[groupDid]/audit/route.ts similarity index 94% rename from src/app/api/organizations/[groupDid]/audit/route.ts rename to src/app/api/groups/[groupDid]/audit/route.ts index 4e9c2b6d..936639f3 100644 --- a/src/app/api/organizations/[groupDid]/audit/route.ts +++ b/src/app/api/groups/[groupDid]/audit/route.ts @@ -2,10 +2,10 @@ import { NextRequest, NextResponse } from "next/server" import { getAuthenticatedAgent, createGroupAgent, -} from "@/lib/organizations/proxy-agent" +} from "@/lib/groups/proxy-agent" /** - * GET /api/organizations/[groupDid]/audit + * GET /api/groups/[groupDid]/audit * Query the audit log (requires admin). */ export async function GET( diff --git a/src/app/api/organizations/[groupDid]/bsky-profile/route.ts b/src/app/api/groups/[groupDid]/bsky-profile/route.ts similarity index 93% rename from src/app/api/organizations/[groupDid]/bsky-profile/route.ts rename to src/app/api/groups/[groupDid]/bsky-profile/route.ts index bf0c74b4..85cfc825 100644 --- a/src/app/api/organizations/[groupDid]/bsky-profile/route.ts +++ b/src/app/api/groups/[groupDid]/bsky-profile/route.ts @@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from "next/server" import { getAuthenticatedAgent, createGroupAgent, -} from "@/lib/organizations/proxy-agent" +} from "@/lib/groups/proxy-agent" import { checkCsrf } from "@/lib/auth/csrf" /** - * POST /api/organizations/[groupDid]/bsky-profile + * POST /api/groups/[groupDid]/bsky-profile * Create an empty app.bsky.actor.profile record for discoverability. * Uses the group service proxy (custom NSID) for writes. */ diff --git a/src/app/api/organizations/[groupDid]/handle/route.ts b/src/app/api/groups/[groupDid]/handle/route.ts similarity index 90% rename from src/app/api/organizations/[groupDid]/handle/route.ts rename to src/app/api/groups/[groupDid]/handle/route.ts index 8b431d4d..4407d0c2 100644 --- a/src/app/api/organizations/[groupDid]/handle/route.ts +++ b/src/app/api/groups/[groupDid]/handle/route.ts @@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from "next/server" import { getAuthenticatedAgent, createGroupAgent, -} from "@/lib/organizations/proxy-agent" +} from "@/lib/groups/proxy-agent" import { checkCsrf } from "@/lib/auth/csrf" /** - * PUT /api/organizations/[groupDid]/handle - * Update the organization's handle via the group service proxy. + * PUT /api/groups/[groupDid]/handle + * Update the group's handle via the group service proxy. */ export async function PUT( request: NextRequest, diff --git a/src/app/api/organizations/[groupDid]/members/route.ts b/src/app/api/groups/[groupDid]/members/route.ts similarity index 90% rename from src/app/api/organizations/[groupDid]/members/route.ts rename to src/app/api/groups/[groupDid]/members/route.ts index d0b44687..5df157c0 100644 --- a/src/app/api/organizations/[groupDid]/members/route.ts +++ b/src/app/api/groups/[groupDid]/members/route.ts @@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from "next/server" import { getAuthenticatedAgent, createGroupAgent, -} from "@/lib/organizations/proxy-agent" +} from "@/lib/groups/proxy-agent" import { checkCsrf } from "@/lib/auth/csrf" /** - * GET /api/organizations/[groupDid]/members + * GET /api/groups/[groupDid]/members * List members (any member can do this). */ export async function GET( @@ -20,11 +20,12 @@ export async function GET( return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) const groupAgent = createGroupAgent(auth.agent, groupDid) - const limit = request.nextUrl.searchParams.get("limit") || "50" + const rawLimit = parseInt(request.nextUrl.searchParams.get("limit") || "50", 10) + const limit = isNaN(rawLimit) ? 50 : Math.min(Math.max(1, rawLimit), 100) const { data } = await groupAgent.call( "app.certified.group.member.list", - { limit: Number(limit) } + { limit } ) return NextResponse.json(data) @@ -38,7 +39,7 @@ export async function GET( } /** - * POST /api/organizations/[groupDid]/members + * POST /api/groups/[groupDid]/members * Add a member (requires admin). */ export async function POST( @@ -86,7 +87,7 @@ export async function POST( } /** - * DELETE /api/organizations/[groupDid]/members + * DELETE /api/groups/[groupDid]/members * Remove a member (requires admin, or self-removal). */ export async function DELETE( diff --git a/src/app/api/organizations/[groupDid]/metadata/route.ts b/src/app/api/groups/[groupDid]/metadata/route.ts similarity index 93% rename from src/app/api/organizations/[groupDid]/metadata/route.ts rename to src/app/api/groups/[groupDid]/metadata/route.ts index f3a9c49f..cb0b4189 100644 --- a/src/app/api/organizations/[groupDid]/metadata/route.ts +++ b/src/app/api/groups/[groupDid]/metadata/route.ts @@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from "next/server" import { getAuthenticatedAgent, createGroupAgent, -} from "@/lib/organizations/proxy-agent" +} from "@/lib/groups/proxy-agent" import { resolvePdsUrl } from "@/lib/atproto/did" import { checkCsrf } from "@/lib/auth/csrf" /** - * GET /api/organizations/[groupDid]/metadata + * GET /api/groups/[groupDid]/metadata * Read the org's app.certified.actor.organization record. * Reads go directly to the group's own PDS. */ @@ -47,8 +47,8 @@ export async function GET( } /** - * PUT /api/organizations/[groupDid]/metadata - * Update the org's organization record. + * PUT /api/groups/[groupDid]/metadata + * Update the group's metadata record. */ export async function PUT( request: NextRequest, diff --git a/src/app/api/organizations/[groupDid]/profile/route.ts b/src/app/api/groups/[groupDid]/profile/route.ts similarity index 88% rename from src/app/api/organizations/[groupDid]/profile/route.ts rename to src/app/api/groups/[groupDid]/profile/route.ts index 84e68845..95e2857f 100644 --- a/src/app/api/organizations/[groupDid]/profile/route.ts +++ b/src/app/api/groups/[groupDid]/profile/route.ts @@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from "next/server" import { getAuthenticatedAgent, createGroupAgent, -} from "@/lib/organizations/proxy-agent" +} from "@/lib/groups/proxy-agent" import { resolvePdsUrl } from "@/lib/atproto/did" import { checkCsrf } from "@/lib/auth/csrf" /** - * GET /api/organizations/[groupDid]/profile + * GET /api/groups/[groupDid]/profile * Read the org's app.certified.actor.profile record. * Reads go directly to the group's own PDS (resolved from the DID document). */ @@ -49,7 +49,7 @@ export async function GET( } /** - * PUT /api/organizations/[groupDid]/profile + * PUT /api/groups/[groupDid]/profile * Update the org's profile. Uses custom NSID for writes. * Requires admin role. */ @@ -69,10 +69,8 @@ export async function PUT( const body = await request.json() const groupAgent = createGroupAgent(auth.agent, groupDid) - console.log("PUT org profile: writing to", groupDid, "body:", JSON.stringify(body).slice(0, 200)) - // Use custom NSID for writes — PDS proxies to group service - const writeResult = await groupAgent.call( + await groupAgent.call( "app.certified.group.repo.putRecord", {}, { @@ -87,7 +85,6 @@ export async function PUT( { encoding: "application/json" } ) - console.log("PUT org profile: success", JSON.stringify(writeResult.data).slice(0, 200)) return NextResponse.json({ success: true }) } catch (err: unknown) { console.error("PUT org profile error:", err) diff --git a/src/app/api/organizations/[groupDid]/role/route.ts b/src/app/api/groups/[groupDid]/role/route.ts similarity index 94% rename from src/app/api/organizations/[groupDid]/role/route.ts rename to src/app/api/groups/[groupDid]/role/route.ts index cf33cbf3..05f541cc 100644 --- a/src/app/api/organizations/[groupDid]/role/route.ts +++ b/src/app/api/groups/[groupDid]/role/route.ts @@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from "next/server" import { getAuthenticatedAgent, createGroupAgent, -} from "@/lib/organizations/proxy-agent" +} from "@/lib/groups/proxy-agent" import { checkCsrf } from "@/lib/auth/csrf" /** - * PUT /api/organizations/[groupDid]/role + * PUT /api/groups/[groupDid]/role * Set a member's role (requires owner). */ export async function PUT( diff --git a/src/app/api/organizations/[groupDid]/upload-blob/route.ts b/src/app/api/groups/[groupDid]/upload-blob/route.ts similarity index 94% rename from src/app/api/organizations/[groupDid]/upload-blob/route.ts rename to src/app/api/groups/[groupDid]/upload-blob/route.ts index aca158a6..40f0e785 100644 --- a/src/app/api/organizations/[groupDid]/upload-blob/route.ts +++ b/src/app/api/groups/[groupDid]/upload-blob/route.ts @@ -2,14 +2,14 @@ import { NextRequest, NextResponse } from "next/server" import { getAuthenticatedAgent, createGroupAgent, -} from "@/lib/organizations/proxy-agent" +} from "@/lib/groups/proxy-agent" import { checkCsrf } from "@/lib/auth/csrf" const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"] const MAX_SIZE = 5 * 1024 * 1024 // 5MB /** - * POST /api/organizations/[groupDid]/upload-blob + * POST /api/groups/[groupDid]/upload-blob * Upload a blob to the group's repo via the group service proxy. */ export async function POST( diff --git a/src/app/api/organizations/memberships/route.ts b/src/app/api/groups/memberships/route.ts similarity index 91% rename from src/app/api/organizations/memberships/route.ts rename to src/app/api/groups/memberships/route.ts index 427153d6..d619af72 100644 --- a/src/app/api/organizations/memberships/route.ts +++ b/src/app/api/groups/memberships/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" -import { getAuthenticatedAgent } from "@/lib/organizations/proxy-agent" -import { GROUP_SERVICE, GROUP_SERVICE_DID } from "@/lib/organizations/constants" +import { getAuthenticatedAgent } from "@/lib/groups/proxy-agent" +import { GROUP_SERVICE, GROUP_SERVICE_DID } from "@/lib/groups/constants" export async function GET(request: NextRequest) { try { diff --git a/src/app/api/organizations/register/route.ts b/src/app/api/groups/register/route.ts similarity index 84% rename from src/app/api/organizations/register/route.ts rename to src/app/api/groups/register/route.ts index 87f1c21e..0e04a49e 100644 --- a/src/app/api/organizations/register/route.ts +++ b/src/app/api/groups/register/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server" -import { getAuthenticatedAgent, getServiceAuthToken, createGroupAgent } from "@/lib/organizations/proxy-agent" -import { GROUP_SERVICE, GROUP_SERVICE_DID, MAX_SELF_CREATED_ORGS } from "@/lib/organizations/constants" +import { getAuthenticatedAgent, getServiceAuthToken, createGroupAgent } from "@/lib/groups/proxy-agent" +import { GROUP_SERVICE, GROUP_SERVICE_DID, MAX_SELF_CREATED_ORGS } from "@/lib/groups/constants" import { checkCsrf } from "@/lib/auth/csrf" +import { extractError } from "@/lib/utils/api" export async function POST(request: NextRequest) { const csrfError = checkCsrf(request) @@ -91,12 +92,16 @@ export async function POST(request: NextRequest) { if (selfCreatedCount >= MAX_SELF_CREATED_ORGS) { return NextResponse.json( - { error: `You have reached the maximum number of organizations you can create (${MAX_SELF_CREATED_ORGS})` }, + { error: `You have reached the maximum number of groups you can create (${MAX_SELF_CREATED_ORGS})` }, { status: 403 } ) } - } catch { - // If limit check fails, allow creation (fail open) + } catch (err) { + console.error("Org creation limit check failed:", err) + return NextResponse.json( + { error: "Unable to verify group creation limit. Please try again." }, + { status: 503 } + ) } // Get service auth JWT for group registration @@ -119,9 +124,15 @@ export async function POST(request: NextRequest) { ) if (!res.ok) { - const data = await res.json().catch(() => ({})) + // Sanitize upstream 5xx — don't leak internal error details + if (res.status >= 500) { + return NextResponse.json( + { error: "Registration failed" }, + { status: 502 } + ) + } return NextResponse.json( - { error: (data as { message?: string }).message || `Registration failed: ${res.status}` }, + { error: await extractError(res, `Registration failed: ${res.status}`) }, { status: res.status } ) } diff --git a/src/app/api/resolve-did/route.ts b/src/app/api/resolve-did/route.ts index 2d27441e..082c3c20 100644 --- a/src/app/api/resolve-did/route.ts +++ b/src/app/api/resolve-did/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { resolveHandle } from "@/lib/atproto/did" -import { getAuthenticatedAgent } from "@/lib/organizations/proxy-agent" +import { getAuthenticatedAgent } from "@/lib/groups/proxy-agent" /** * GET /api/resolve-did?did= diff --git a/src/app/api/resolve-handle/route.ts b/src/app/api/resolve-handle/route.ts index 939579a9..bd762b49 100644 --- a/src/app/api/resolve-handle/route.ts +++ b/src/app/api/resolve-handle/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server" -import { getAuthenticatedAgent } from "@/lib/organizations/proxy-agent" +import { getAuthenticatedAgent } from "@/lib/groups/proxy-agent" /** * GET /api/resolve-handle?handle= diff --git a/src/app/api/search-actors/route.ts b/src/app/api/search-actors/route.ts index 90c666af..121704a0 100644 --- a/src/app/api/search-actors/route.ts +++ b/src/app/api/search-actors/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server" -import { getAuthenticatedAgent } from "@/lib/organizations/proxy-agent" +import { getAuthenticatedAgent } from "@/lib/groups/proxy-agent" /** * GET /api/search-actors?q=&limit= diff --git a/src/app/api/xrpc/[...method]/route.ts b/src/app/api/xrpc/[...method]/route.ts index be252533..ac22ff81 100644 --- a/src/app/api/xrpc/[...method]/route.ts +++ b/src/app/api/xrpc/[...method]/route.ts @@ -1,9 +1,18 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { NextRequest, NextResponse } from "next/server" import { Agent } from "@atproto/api" +import type { + ComAtprotoRepoCreateRecord, + ComAtprotoRepoPutRecord, + ComAtprotoRepoDeleteRecord, + ComAtprotoIdentityUpdateHandle, + ComAtprotoServerRequestPasswordReset, + ComAtprotoServerResetPassword, + ComAtprotoServerUpdateEmail, +} from "@atproto/api" import { getOAuthClient } from "@/lib/auth/oauth-client" import { getSessionDid, deleteSession } from "@/lib/auth/session" import { checkCsrf } from "@/lib/auth/csrf" +import { LIMIT_MIN, LIMIT_MAX } from "@/lib/utils/constants" const ALLOWED_WRITE_COLLECTIONS = [ "org.impactindexer.link.attestation", @@ -22,6 +31,25 @@ const ALLOWED_BLOB_CONTENT_TYPES = [ const MAX_BLOB_SIZE = 4 * 1024 * 1024 // 4MB — Vercel serverless functions have a ~4.5MB request body limit +/** Extract a usable HTTP status + message from an unknown XRPC error. */ +function xrpcError(err: unknown): { status: number; message: string } { + const error = err as { status?: number; statusCode?: number; message?: string } + const status = error?.status ?? error?.statusCode ?? 500 + const message = + status >= 500 + ? "Internal server error" + : (error?.message ?? "Internal server error") + return { status, message } +} + +/** Clamp and validate a limit query param. */ +function parseLimit(raw: string | undefined): number | undefined { + if (!raw) return undefined + const n = parseInt(raw, 10) + if (isNaN(n)) return undefined + return Math.min(Math.max(LIMIT_MIN, n), LIMIT_MAX) +} + export async function GET( request: NextRequest, { params }: { params: Promise<{ method: string[] }> } @@ -44,22 +72,36 @@ export async function GET( } const agent = new Agent(oauthSession) - const queryParams = Object.fromEntries( + // Query params come as Record from URLSearchParams. + // AT Protocol SDK expects specific typed params — we validate the required + // fields per-method below and cast through unknown for the proxy pattern. + const queryParams: Record = Object.fromEntries( request.nextUrl.searchParams.entries() ) switch (methodName) { case "com.atproto.repo.getRecord": { - const result = await agent.com.atproto.repo.getRecord( - queryParams as any - ) + const { repo, collection, rkey, cid } = queryParams + if (!repo || !collection || !rkey) { + return NextResponse.json({ error: "repo, collection, and rkey are required" }, { status: 400 }) + } + const result = await agent.com.atproto.repo.getRecord({ repo, collection, rkey, cid }) return NextResponse.json(result.data) } case "com.atproto.repo.listRecords": { + const { repo, collection, cursor, reverse, rkeyEnd, rkeyStart } = queryParams + if (!repo || !collection) { + return NextResponse.json({ error: "repo and collection are required" }, { status: 400 }) + } const result = await agent.com.atproto.repo.listRecords({ - ...queryParams, - limit: queryParams.limit ? Number(queryParams.limit) : undefined, - } as any) + repo, + collection, + limit: parseLimit(queryParams.limit), + cursor, + reverse: reverse === "true" ? true : undefined, + rkeyEnd, + rkeyStart, + }) return NextResponse.json(result.data) } case "com.atproto.server.getSession": { @@ -67,10 +109,13 @@ export async function GET( return NextResponse.json(result.data) } case "com.atproto.sync.getBlob": { - const result = await agent.com.atproto.sync.getBlob( - queryParams as any - ) - return new NextResponse(result.data as any, { + const { did: blobDid, cid } = queryParams + if (!blobDid || !cid) { + return NextResponse.json({ error: "did and cid are required" }, { status: 400 }) + } + const result = await agent.com.atproto.sync.getBlob({ did: blobDid, cid }) + const blob = result.data as Uint8Array + return new NextResponse(Buffer.from(blob), { headers: { "Content-Type": result.headers["content-type"] || "application/octet-stream", @@ -84,12 +129,7 @@ export async function GET( ) } } catch (err: unknown) { - const error = err as { status?: number; statusCode?: number; message?: string } - const status = error?.status ?? error?.statusCode ?? 500 - const message = - status >= 500 - ? "Internal server error" - : (error?.message ?? "Internal server error") + const { status, message } = xrpcError(err) return NextResponse.json({ error: message }, { status }) } } @@ -126,7 +166,7 @@ export async function POST( } // Validate repo on write methods — reject cross-repo writes - const REPO_METHODS = ["com.atproto.repo.putRecord", "com.atproto.repo.deleteRecord"] + const REPO_METHODS = ["com.atproto.repo.createRecord", "com.atproto.repo.putRecord", "com.atproto.repo.deleteRecord"] if (body && REPO_METHODS.includes(methodName)) { if (body.repo && body.repo !== did) { return NextResponse.json( @@ -147,12 +187,22 @@ export async function POST( } switch (methodName) { + case "com.atproto.repo.createRecord": { + const result = await agent.com.atproto.repo.createRecord( + body as ComAtprotoRepoCreateRecord.InputSchema + ) + return NextResponse.json(result.data) + } case "com.atproto.repo.putRecord": { - const result = await agent.com.atproto.repo.putRecord(body as any) + const result = await agent.com.atproto.repo.putRecord( + body as ComAtprotoRepoPutRecord.InputSchema + ) return NextResponse.json(result.data) } case "com.atproto.repo.deleteRecord": { - const result = await agent.com.atproto.repo.deleteRecord(body as any) + const result = await agent.com.atproto.repo.deleteRecord( + body as ComAtprotoRepoDeleteRecord.InputSchema + ) return NextResponse.json(result.data) } case "com.atproto.repo.uploadBlob": { @@ -188,15 +238,22 @@ export async function POST( return NextResponse.json(result.data) } case "com.atproto.identity.updateHandle": { - await agent.com.atproto.identity.updateHandle(body as any) + await agent.com.atproto.identity.updateHandle( + body as ComAtprotoIdentityUpdateHandle.InputSchema + ) + // Void operation — no data to return return NextResponse.json({}) } case "com.atproto.server.requestPasswordReset": { - await agent.com.atproto.server.requestPasswordReset(body as any) + await agent.com.atproto.server.requestPasswordReset( + body as ComAtprotoServerRequestPasswordReset.InputSchema + ) return NextResponse.json({}) } case "com.atproto.server.resetPassword": { - await agent.com.atproto.server.resetPassword(body as any) + await agent.com.atproto.server.resetPassword( + body as ComAtprotoServerResetPassword.InputSchema + ) return NextResponse.json({}) } case "com.atproto.server.requestEmailUpdate": { @@ -204,7 +261,9 @@ export async function POST( return NextResponse.json(result.data) } case "com.atproto.server.updateEmail": { - await agent.com.atproto.server.updateEmail(body as any) + await agent.com.atproto.server.updateEmail( + body as ComAtprotoServerUpdateEmail.InputSchema + ) return NextResponse.json({}) } default: @@ -214,12 +273,7 @@ export async function POST( ) } } catch (err: unknown) { - const error = err as { status?: number; statusCode?: number; message?: string } - const status = error?.status ?? error?.statusCode ?? 500 - const message = - status >= 500 - ? "Internal server error" - : (error?.message ?? "Internal server error") + const { status, message } = xrpcError(err) return NextResponse.json({ error: message }, { status }) } } diff --git a/src/app/dsa/page.tsx b/src/app/dsa/page.tsx index b2d8d0ad..8223481b 100644 --- a/src/app/dsa/page.tsx +++ b/src/app/dsa/page.tsx @@ -11,6 +11,7 @@ export const metadata: Metadata = { "Digital Services Act compliance information for Certified, operated by the Hypercerts Foundation.", url: "https://certified.app/dsa", type: "website", + images: [{ url: "/assets/certified-hero-1200x630.png", width: 1200, height: 630, alt: "Certified — One account, any app" }], }, }; diff --git a/src/app/globals.css b/src/app/globals.css index 1424a6bd..0392bf01 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -78,6 +78,10 @@ --color-warning-border: #f5d880; --color-warning-text: #7a6420; + /* Focus / Success accents */ + --color-focus-green: #94BB51; + --color-success-icon: #28a745; + /* Transitions */ --transition-fast: 150ms ease-out; --transition-base: 250ms ease-out; @@ -774,7 +778,7 @@ h1, h2, h3, h4, h5, h6 { } .signin-modal__input:focus { - border-color: #94BB51; + border-color: var(--color-focus-green); box-shadow: 0 0 0 3px rgba(148, 187, 81, 0.15); } @@ -2650,7 +2654,7 @@ h1, h2, h3, h4, h5, h6 { } .domain-modal__success-icon { - color: #28a745; + color: var(--color-success-icon); } .domain-modal__success-title { @@ -4630,18 +4634,35 @@ h1, h2, h3, h4, h5, h6 { } .account-switcher__user-row .account-switcher__item { - flex: 1; + flex: 1 1 0%; min-width: 0; + padding-right: 0; + background: none; +} + +.account-switcher__user-row .account-switcher__item > div { + min-width: 0; +} + +.account-switcher__user-row .account-switcher__item-name, +.account-switcher__user-row .account-switcher__item-handle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-switcher__user-row:has(.account-switcher__item--active) { + background: var(--color-off-white); } .account-switcher__signout { display: flex; align-items: center; justify-content: center; - width: 36px; - height: 36px; + width: 40px; + height: 40px; flex-shrink: 0; - margin-right: 12px; + margin-right: 8px; border: none; background: none; color: var(--color-mid-gray); @@ -4712,6 +4733,7 @@ h1, h2, h3, h4, h5, h6 { } .bottom-sheet__content { + overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; padding-bottom: env(safe-area-inset-bottom, 16px); diff --git a/src/app/organizations/[groupDid]/apps/page.tsx b/src/app/groups/[groupDid]/apps/page.tsx similarity index 91% rename from src/app/organizations/[groupDid]/apps/page.tsx rename to src/app/groups/[groupDid]/apps/page.tsx index 27303f81..549330b7 100644 --- a/src/app/organizations/[groupDid]/apps/page.tsx +++ b/src/app/groups/[groupDid]/apps/page.tsx @@ -18,7 +18,7 @@ export default function OrgAppsPage() {

- Apps built on the AT Protocol. Use your organization's identity to get started. + Apps built on the AT Protocol. Use your group's identity to get started.

{CONNECTED_APPS.map((app) => ( diff --git a/src/app/organizations/[groupDid]/edit-profile/page.tsx b/src/app/groups/[groupDid]/edit-profile/page.tsx similarity index 96% rename from src/app/organizations/[groupDid]/edit-profile/page.tsx rename to src/app/groups/[groupDid]/edit-profile/page.tsx index 4f4fa349..20c81884 100644 --- a/src/app/organizations/[groupDid]/edit-profile/page.tsx +++ b/src/app/groups/[groupDid]/edit-profile/page.tsx @@ -2,10 +2,10 @@ import React, { useState, useEffect, useCallback } from "react" import { useRouter, useParams } from "next/navigation" -import { getOrgProfile, putOrgProfile, getOrgMetadata, putOrgMetadata, uploadOrgBlob } from "@/lib/organizations/api" +import { getOrgProfile, putOrgProfile, getOrgMetadata, putOrgMetadata, uploadOrgBlob } from "@/lib/groups/api" import { resolvePdsUrl } from "@/lib/atproto/did" import { getAvatarUrl, getBannerUrl } from "@/lib/atproto/profile" -import type { OrgProfile, OrgOrganization } from "@/lib/organizations/types" +import type { OrgProfile, GroupMetadata } from "@/lib/groups/types" import type { CertifiedProfile, HypercertsSmallImage, HypercertsLargeImage } from "@/lib/atproto/types" import Input from "@/components/ui/input" import Textarea from "@/components/ui/textarea" @@ -21,7 +21,7 @@ export default function EditOrgProfilePage() { const groupDid = decodeURIComponent(params.groupDid as string) const [profile, setProfile] = useState(null) - const [metadata, setMetadata] = useState(null) + const [metadata, setMetadata] = useState(null) const [pdsUrl, setPdsUrl] = useState(null) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) @@ -32,7 +32,7 @@ export default function EditOrgProfilePage() { const [description, setDescription] = useState("") const [website, setWebsite] = useState("") - // Organization metadata fields + // Group metadata fields const [foundedDate, setFoundedDate] = useState("") // Image upload state @@ -139,7 +139,7 @@ export default function EditOrgProfilePage() { } // Build metadata update - const updatedMetadata: OrgOrganization = { + const updatedMetadata: GroupMetadata = { createdAt: metadata?.createdAt || new Date().toISOString(), ...(metadata?.organizationType && { organizationType: metadata.organizationType }), ...(metadata?.urls && { urls: metadata.urls }), diff --git a/src/app/organizations/[groupDid]/page.tsx b/src/app/groups/[groupDid]/page.tsx similarity index 88% rename from src/app/organizations/[groupDid]/page.tsx rename to src/app/groups/[groupDid]/page.tsx index 4259f0c9..cbe7c9a4 100644 --- a/src/app/organizations/[groupDid]/page.tsx +++ b/src/app/groups/[groupDid]/page.tsx @@ -4,9 +4,9 @@ import React, { useState, useEffect, useCallback } from "react" import { useRouter, useParams } from "next/navigation" import { ArrowLeft, Edit3 } from "lucide-react" import { useAuth } from "@/lib/auth/auth-context" -import { useOrg } from "@/lib/organizations/org-context" -import { getOrgProfile } from "@/lib/organizations/api" -import type { OrgProfile } from "@/lib/organizations/types" +import { useOrg } from "@/lib/groups/org-context" +import { getOrgProfile } from "@/lib/groups/api" +import type { OrgProfile } from "@/lib/groups/types" import LoadingSpinner from "@/components/ui/loading-spinner" import Button from "@/components/ui/button" import Avatar from "@/components/ui/avatar" @@ -16,12 +16,12 @@ export default function OrgProfilePage() { const params = useParams() const groupDid = decodeURIComponent(params.groupDid as string) const { did } = useAuth() - const { organizations } = useOrg() + const { groups } = useOrg() const [profile, setProfile] = useState(null) const [isLoading, setIsLoading] = useState(true) - const org = organizations.find((o) => o.groupDid === groupDid) + const org = groups.find((o) => o.groupDid === groupDid) const fetchProfile = useCallback(async (signal?: AbortSignal) => { try { @@ -47,12 +47,12 @@ export default function OrgProfilePage() {

- {profile?.displayName || org?.displayName || "Organization"} + {profile?.displayName || org?.displayName || "Group"}

@@ -175,11 +175,11 @@ export default function CreateOrganizationPage() { return (
-

Create Organization

+

Create Group

diff --git a/src/app/organizations/layout.tsx b/src/app/groups/layout.tsx similarity index 76% rename from src/app/organizations/layout.tsx rename to src/app/groups/layout.tsx index f60a83d5..1062d3a8 100644 --- a/src/app/organizations/layout.tsx +++ b/src/app/groups/layout.tsx @@ -1,6 +1,6 @@ import AuthGuard from "@/components/layout/auth-guard" -export default function OrganizationsLayout({ +export default function GroupsLayout({ children, }: { children: React.ReactNode diff --git a/src/app/organizations/page.tsx b/src/app/groups/page.tsx similarity index 78% rename from src/app/organizations/page.tsx rename to src/app/groups/page.tsx index 610b1f9a..81132716 100644 --- a/src/app/organizations/page.tsx +++ b/src/app/groups/page.tsx @@ -1,34 +1,32 @@ "use client" -import React, { useState, useMemo, useEffect, useCallback } from "react" +import React, { useState, useMemo } from "react" import Link from "next/link" import { Building2, Plus, LogOut, UserCheck, UserX } from "lucide-react" -import { useOrg } from "@/lib/organizations/org-context" +import { useOrg } from "@/lib/groups/org-context" import { useAuth } from "@/lib/auth/auth-context" import { putMembership, deleteMembership, removeOrgMember, - listOrgMembers, -} from "@/lib/organizations/api" -import type { OrgRole } from "@/lib/organizations/types" +} from "@/lib/groups/api" +import type { OrgRole } from "@/lib/groups/types" import Avatar from "@/components/ui/avatar" import LoadingSpinner from "@/components/ui/loading-spinner" import Button from "@/components/ui/button" -export default function OrganizationsPage() { - const { organizations, isLoading, refetchOrgs } = useOrg() +export default function GroupsPage() { + const { groups, isLoading, refetchOrgs } = useOrg() const { did } = useAuth() const [leaveOrg, setLeaveOrg] = useState<{ groupDid: string; name: string } | null>(null) const [isLeaving, setIsLeaving] = useState(false) const [acceptingOrg, setAcceptingOrg] = useState(null) - const [canLeaveMap, setCanLeaveMap] = useState>({}) const [removingPublic, setRemovingPublic] = useState(null) const ROLE_ORDER: Record = { owner: 0, admin: 1, member: 2 } const sortedOrgs = useMemo(() => { - return [...organizations].sort((a, b) => { + return [...groups].sort((a, b) => { // Accepted first if (a.accepted !== b.accepted) return a.accepted ? -1 : 1 // Then by role @@ -40,33 +38,19 @@ export default function OrganizationsPage() { const nameB = (b.displayName || b.handle).toLowerCase() return nameA.localeCompare(nameB) }) - }, [organizations]) + }, [groups]) const acceptedOrgs = sortedOrgs.filter((o) => o.accepted) const pendingOrgs = sortedOrgs.filter((o) => !o.accepted) - const checkCanLeave = useCallback(async () => { - if (!organizations.length || !did) return + // Owners can never leave — grey out the button. Non-owners can always leave. + const canLeaveMap = useMemo(() => { const map: Record = {} - for (const org of organizations) { - if (org.role !== "owner") { - map[org.groupDid] = true - continue - } - try { - const members = await listOrgMembers(org.groupDid) - const ownerCount = members.filter((m) => m.role === "owner").length - map[org.groupDid] = ownerCount > 1 - } catch { - map[org.groupDid] = false - } + for (const org of groups) { + map[org.groupDid] = org.role !== "owner" } - setCanLeaveMap(map) - }, [organizations, did]) - - useEffect(() => { - checkCanLeave() - }, [checkCanLeave]) + return map + }, [groups]) const renderOrgItem = (org: (typeof sortedOrgs)[number]) => (
@@ -111,7 +95,7 @@ export default function OrganizationsPage() { className="org-list__leave-btn" onClick={() => setLeaveOrg({ groupDid: org.groupDid, name: org.displayName || org.handle })} disabled={!canLeaveMap[org.groupDid]} - title={canLeaveMap[org.groupDid] === false ? "Transfer ownership before leaving" : "Leave group"} + title={!canLeaveMap[org.groupDid] ? "Owners can't leave the group" : "Leave group"} > @@ -125,8 +109,8 @@ export default function OrganizationsPage() { try { await deleteMembership(did, groupDid) await refetchOrgs() - } catch { - // ignore + } catch (err) { + console.error("Failed to remove public membership:", err) } finally { setRemovingPublic(null) } @@ -142,8 +126,8 @@ export default function OrganizationsPage() { await deleteMembership(did, leaveOrg.groupDid).catch(() => {}) await refetchOrgs() setLeaveOrg(null) - } catch { - // ignore + } catch (err) { + console.error("Failed to leave group:", err) } finally { setIsLeaving(false) } @@ -155,8 +139,8 @@ export default function OrganizationsPage() { try { await putMembership(did, groupDid, role) await refetchOrgs() - } catch { - // ignore + } catch (err) { + console.error("Failed to accept membership:", err) } finally { setAcceptingOrg(null) } @@ -167,7 +151,7 @@ export default function OrganizationsPage() {

Groups

- +
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2048d363..edacc68d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,7 +7,7 @@ import Navbar from "@/components/layout/navbar"; import Footer from "@/components/layout/footer"; import { Providers } from "@/lib/providers"; import AppShell from "@/components/layout/app-shell"; -import { OrgProvider } from "@/lib/organizations/org-context"; +import { OrgProvider } from "@/lib/groups/org-context"; import FeedbackModal from "@/components/ui/feedback-modal"; import { Analytics } from "@vercel/analytics/next"; @@ -15,6 +15,7 @@ const inter = Inter({ subsets: ["latin"], weight: ["300", "400", "500", "600", "700"], variable: "--font-inter", + display: "swap", }); const notoSerif = Noto_Serif({ @@ -22,6 +23,7 @@ const notoSerif = Noto_Serif({ weight: ["400", "700"], style: ["normal", "italic"], variable: "--font-headline", + display: "swap", }); const instrumentSerif = Instrument_Serif({ @@ -29,6 +31,7 @@ const instrumentSerif = Instrument_Serif({ weight: ["400"], style: ["normal", "italic"], variable: "--font-serif-alt", + display: "swap", }); export const metadata: Metadata = { @@ -53,6 +56,8 @@ export const metadata: Metadata = { }, twitter: { card: "summary_large_image", + site: "@hypercerts", + creator: "@hypercerts", images: ["/assets/certified-hero-1200x630.png"], }, }; @@ -61,13 +66,18 @@ export const viewport: Viewport = { themeColor: "#f9f9f6", }; -const organizationJsonLd = { +const groupJsonLd = { "@context": "https://schema.org", "@type": "Organization", name: "Hypercerts Foundation", legalName: "Hypercerts Foundation", url: "https://hypercerts.org", - logo: "https://certified.app/assets/certified_brandmark_black.png", + logo: { + "@type": "ImageObject", + url: "https://certified.app/assets/certified_brandmark_black.png", + width: 512, + height: 512, + }, description: "A Delaware nonstock corporation that develops open infrastructure for the hypercerts ecosystem, operating the Certified identity platform.", foundingDate: "2023-02-03", @@ -117,7 +127,7 @@ export default function RootLayout({