First Commit
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 5m26s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

This commit is contained in:
Denozordec
2026-07-18 13:27:37 +07:00
commit bac95bdb2e
154 changed files with 26610 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
+43
View File
@@ -0,0 +1,43 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something.
## Operational surfaces (settings / profile / admin)
Pick ONE archetype and keep the family consistent: a vertical rail (3-6 sections), horizontal tabs (5-8), or a frame/stack. Prefer `frame` for tool-like surfaces, a card for profile-like ones. Don't mix archetypes in one surface.
+58
View File
@@ -0,0 +1,58 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks; Motion Icons and templates) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
+330
View File
@@ -0,0 +1,330 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
// add sorting/pagination/selection models per the API
})
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<KanbanColumnHandle><h3>{id}</h3></KanbanColumnHandle>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
]
<Filters filters={filters} fields={fields} onChange={setFilters} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/ui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window).
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FramePanel>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<div className="p-5">Content</div>
<FrameFooter>Footer</FrameFooter>
</FramePanel>
</Frame>
```
**Gotcha:** a structured card shell for tool-like surfaces. `stacked` connects multiple panels with shared borders; `dense` removes panel padding; radius via the `--frame-radius` CSS variable.
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds. Defer generic alert rules to the shadcn skill.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light" size="sm">Success</Badge>
<Badge variant="outline" radius="full">Pill</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl`, `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes for statuses.
## base vs radix - write for the project's base
ReUI ships every component in two builds: `base` (Base UI) and `radix` (Radix UI). The install command and name are identical, and the CLI installs the build matching the project. But you must write/adapt code against the **right base**, because their APIs differ.
**Detect the base first.** Read `components.json` -> `style` and take the segment before the first `-`:
- `"style": "base-nova"` -> **Base UI**
- `"style": "radix-nova"` -> **Radix UI**
**Then use that base's API.** The deltas mirror shadcn's base-vs-radix split:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
The safest path is to **read the installed files and `c-*` examples** - they're already in your base, so reuse their wiring instead of guessing. When `get_component`'s inline `api` or an example shows the other base's shape, translate it to your base (or `validate_usage` to confirm). Defer the generic base/radix mechanics to the shadcn skill.
+45
View File
@@ -0,0 +1,45 @@
# Craft: make ReUI UI exceptional, not generic
ReUI items ship senior-designer quality. Your adaptation has to hold that bar, so the result reads like a real product surface a team would keep - not a wireframe an AI generated. Use these alongside the reuse rules in [adapting.md](./adapting.md).
## Have a point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
## Brutally clear hierarchy
One focal point per card or panel: the dominant metric or task first, its label second, supporting detail third. The first thing the eye lands on should be the right thing; secondary text must read as secondary. Borders, separators, and surfaces do real work to create 2-3 information bands - don't flatten everything to equal weight.
## Spacing rhythm and deliberate density
Gaps are a signal, not a default. Keep them intentional and consistent within a family (`gap-1`/`gap-2` for tight operational rows, larger gaps for section breaks), and smaller within a group than between groups. Match the surrounding ReUI density; don't pad an operational surface like a marketing page, and don't drift density mid-section. The composition should still feel authored in grayscale.
## Cover the real states (the usual miss)
A surface isn't done at the happy path. Compose, and wire:
- **Empty** - a purposeful empty state (short message + the primary action), never a blank panel.
- **Loading** - a **skeleton** that matches the real layout, not a centered spinner.
- **Error** - an inline, recoverable error with a retry, announced via `role="status"`/`aria-live`.
Derive these from an element the block already has (don't invent parallel markup), or `get_examples` for a state-specific example.
## Responsive by default
Mobile-first, not mobile-afterthought. In constrained rows/cards/sidebars, put `min-w-0` on the shrinking container and `truncate` long single-line labels; protect the primary label's width and let secondary content compress. Reflow layouts (multi-column -> single column) rather than just shrinking them. Desktop and mobile should both look designed.
## Motion, subtly
Motion should clarify, not decorate. Use ReUI Motion Icons on primary actions for a subtle hover cue; keep transitions short (~200-300ms) with calm easing; prefer a skeleton pulse over a spinner. No bouncing, no gratuitous entrance animations on every element.
## Real, activated content
Use believable, typed data (realistic labels, counts, timestamps, statuses that map to a real workflow) - never lorem or abstract filler. Every visible control does something: no decorative buttons, fake tabs, meaningless toggles, or stats with no job. It must still hold with long names, empty values, and crowded data.
## Avoid the AI tells
These instantly read as generated - don't ship them: equal-weight card walls, empty gradients, repetitive padding everywhere, generic enterprise copy, ornamental icons, and number tiles that don't earn their place.
## The bar
Before you finish, ask: **would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content?** If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
+39
View File
@@ -0,0 +1,39 @@
# Icons (ReUI delta over shadcn)
Follow the shadcn icon rules (use the project's configured `iconLibrary`, `data-icon` on icons inside `Button`, no sizing classes on icons inside components, pass icons as component objects not string keys). ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. When `iconLibrary` is set in `components.json`, the shadcn CLI installs each item's icons in **your** library automatically - you swap nothing. If an installed item's icons don't match your project (for example `iconLibrary` isn't set, so they came in from the item's demo library), change the **import source and component name** to your library, keeping the same icon-name semantics:
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remix` -> `@remixicon/react`
- `hugeicons` -> `@hugeicons/react`
Don't assume `lucide-react`; read `iconLibrary` from `components.json`.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled), each icon in two variants:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated (motion/react)
```
Finding them via the MCP is free; installing requires an Ultimate license (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Reach for a Motion Icon on a primary action when a subtle hover cue helps; keep motion restrained.
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a hover-animated Motion variant.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` when an animated variant exists - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (its live icon-category page) - **share it with the user** so they can SEE the icon before installing.
The `icon-stack` component composes multiple icons into a stacked display.
+22
View File
@@ -0,0 +1,22 @@
# Quality gates (security, accessibility, scroll)
These are the **done gate**, not a nice-to-have: before you call any ReUI work finished, call the MCP `get_audit_checklist` tool and pass every item below (plus the craft bar in [craft.md](./craft.md)). Then typecheck and lint.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship standard keyboard behavior - read each component's inline `api` rather than re-implementing it.
- Announce async UI: loading and error messages use `role="status"` / `aria-live` so they're not silent to screen readers.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
+35
View File
@@ -0,0 +1,35 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry
Install everything through the shadcn CLI: `npx shadcn@latest add @reui/<name> --yes`. The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks adapt to your active theme through semantic tokens and CSS variables - change the theme and every block follows.
## Free vs premium
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+26
View File
@@ -0,0 +1,26 @@
# Styling (ReUI delta over shadcn)
Follow the shadcn skill for the generic rules (semantic colors not raw values, `gap-*` not `space-y-*`, `size-*`, `cn()`, no manual `dark:` overrides, no overlay `z-index`). This file is only the ReUI-specific additions.
## ReUI extended semantic tokens
ReUI adds semantic tokens beyond shadcn's base set. Use these instead of raw colors for status and emphasis:
- `--success` / `--success-foreground`
- `--info` / `--info-foreground`
- `--warning` / `--warning-foreground`
- `--destructive-foreground` (paired with shadcn's `--destructive`)
- `--invert` / `--invert-foreground` (inverted surfaces)
Use them as Tailwind utilities (`bg-success text-success-foreground`, `text-warning`, ...). They are defined in the project's global CSS and registered with Tailwind (`@theme inline` on v4). If a token is missing in the project, add it to the global CSS file (never a new file) following the same `name` / `name-foreground` convention, exactly as the shadcn customization rules describe.
**Incorrect:** `<span className="text-green-600">Active</span>`
**Correct:** `<Badge variant="success">Active</Badge>` or `<span className="text-success">Active</span>`
## Blocks follow your theme
When you install a block it adapts to your active theme through the semantic tokens above and the project's CSS variables. Don't hardcode style-specific values into installed block code and don't fork it to "restyle" - change the theme via the CSS variables / a preset and every block follows. Want a different look? `search` for a block whose design already fits instead of re-skinning one.
## Density and typography rhythm
ReUI operational UI usually feels dense, not airy. Keep the gap between a title and its supporting description tight by default (`gap-0.5`, `space-y-1`, or `space-y-px`), and smaller than the gap between sections. Match the surrounding ReUI density when you add rows or fields; do not pad operational surfaces like a marketing page.
+54
View File
@@ -0,0 +1,54 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent, sections?)` FIRST, before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid", features: ["filters"] })`.
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Always show the preview link.** Whenever you list or recommend items - from `search`, `search_icons`, `list_components`, `compose_page`, or a getter - include each item's `previewUrl` (a live preview page) so the user can SEE it before you install. Blocks and examples link to an individual live preview; icons and components to their live category/component page. This applies to every listing, not only a single pick.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the full API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, subtle motion, and mobile-first responsiveness (see [craft.md](./craft.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+58
View File
@@ -0,0 +1,58 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the daily request limit. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (and optionally the sections you want); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Pass `animated: true` to get only icons that have a hover-animated Motion variant.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`whats_new`** - when your registry knowledge might be stale (a name 404s, the user mentions an item you don't know). Returns items added/removed per build, newest first.
- **`report_issue`** - when an installed item is actually broken (bad source, wrong dependency, broken preview). Goes straight to the ReUI team; rate-limited 5/hour. Not for usage questions.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `whats_new`, `report_issue`, `get_install_command`, `get_project_context`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `features`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- Icon results and `get_icon` include `animated: true` and `installAnimated` when a hover-animated Motion variant exists (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`).
## Error playbook
- **401** - the MCP requires a signed-in ReUI account. The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp) as `Authorization: Bearer`.
- **403 / locked result** - a valid account but the plan does not cover the item: premium blocks need Pro, Motion Icons need Ultimate. Point to https://reui.io/pricing (upgrade). Free accounts still get all components + examples.
- **429** - rate limited (120 requests/min per IP); back off, honor `Retry-After`.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`; check `whats_new` if you suspect a stale name. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp
+3
View File
@@ -0,0 +1,3 @@
{
"mcpServers": {}
}
+11
View File
@@ -0,0 +1,11 @@
---
description: ReUI MCP — registry @reui для auth-portal
globs: apps/web/**, packages/ui/**
alwaysApply: false
---
# ReUI
Primary MCP: `user-reui`. License: `apps/web/.env.local` → `REUI_LICENSE_KEY`.
CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/<name> --yes`
Post-add: shadcn imports → `@authportal/ui/components/*`
+29
View File
@@ -0,0 +1,29 @@
# Database
DATABASE_URL=sqlite:data/app.db
# Auth JWT (access tokens for apps)
JWT_SECRET=dev-secret-change-me
JWT_TTL_HOURS=1
REFRESH_TTL_DAYS=14
ISSUER=https://auth.shnt.top
# Bootstrap admin (created on first start if DB empty)
ADMIN_EMAIL=[email protected]
ADMIN_PASSWORD=admin
ADMIN_NAME=Admin
# Allowed return_to hosts (comma-separated), e.g. .shnt.top or full origins
RETURN_TO_ALLOWLIST=.shnt.top,localhost,http://localhost:5173,http://localhost:5174
# ReUI PRO (apps/web/components.json → @reui Authorization)
# Ключ: https://reui.io/docs/license-setup — класть в apps/web/.env.local (gitignored)
REUI_LICENSE_KEY=
# Local app URLs for SSO Open (apps/web/.env.local)
# VITE_VPS_APP_URL=http://localhost:5173
# VITE_RETURN_TO_ALLOWLIST=.shnt.top,localhost,http://localhost:5173
# Server
SERVER_PORT=8080
STATIC_DIR=
LOG_LEVEL=info
+114
View File
@@ -0,0 +1,114 @@
name: Build and Push Auth Portal Docker Image
on:
push:
branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**']
tags: ['v*']
paths: ['**']
pull_request:
branches: [main, develop]
paths: ['**']
jobs:
build-and-push:
if: startsWith(gitea.ref, 'refs/tags/v') || (gitea.ref_name == 'main' && gitea.event_name == 'push')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: git.shts.su
username: ${{ gitea.actor }}
password: ${{ secrets.ACTIONS_PAT }}
- name: Create version file
run: |
VERSION=$(cat VERSION)
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
BRANCH="${{ gitea.ref_name }}"
echo "APP_VERSION=${VERSION}" > ./version.txt
echo "BUILD_DATE=${BUILD_DATE}" >> ./version.txt
echo "GIT_BRANCH=${BRANCH}" >> ./version.txt
echo "GIT_COMMIT=${{ gitea.sha }}" >> ./version.txt
echo "GIT_COMMIT_SHORT=$(echo ${{ gitea.sha }} | cut -c1-7)" >> ./version.txt
echo "BUILD_TIMESTAMP=$(date -u +%s)" >> ./version.txt
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: git.shts.su/${{ gitea.repository }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ gitea.ref_name == 'main' }}
type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ gitea.ref_name == 'main' }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
NODE_VERSION=22.23.0-bookworm-slim
cache-from: type=registry,ref=git.shts.su/${{ gitea.repository }}:buildcache
cache-to: type=registry,ref=git.shts.su/${{ gitea.repository }}:buildcache,mode=max
provenance: true
create-release:
needs: build-and-push
if: startsWith(gitea.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
run: |
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [[ -n "$LAST_TAG" ]]; then
CHANGELOG=$(git log --pretty=format:"- **%h** %s (%an, %ar)" --no-merges ${LAST_TAG}..HEAD 2>/dev/null || echo "")
else
CHANGELOG=$(git log --pretty=format:"- **%h** %s (%an, %ar)" --no-merges -10 2>/dev/null || echo "")
fi
if [[ -z "$CHANGELOG" ]]; then CHANGELOG="- No changes detected"; fi
echo "CHANGELOG<<EOF" >> $GITEA_OUTPUT
echo "$CHANGELOG" >> $GITEA_OUTPUT
echo "EOF" >> $GITEA_OUTPUT
- name: Create Release
run: |
VERSION="${{ gitea.ref_name }}"
RELEASE_DATA=$(cat <<EOF
{
"tag_name": "${VERSION}",
"target_commitish": "${{ gitea.sha }}",
"name": "Auth Portal ${VERSION}",
"body": "## Auth Portal ${VERSION}\n\n### Docker\n\n\`\`\`bash\ndocker pull git.shts.su/${{ gitea.repository }}:${VERSION}\ndocker run -d -p 8080:8080 -v ./data:/data --name auth-portal git.shts.su/${{ gitea.repository }}:${VERSION}\n\`\`\`\n\n### Changelog\n\n${{ steps.changelog.outputs.CHANGELOG }}",
"draft": false,
"prerelease": false
}
EOF
)
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/release.json -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$RELEASE_DATA" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases")
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 409 ]; then
echo "Release OK (HTTP $HTTP_CODE)"
else
cat /tmp/release.json
exit 1
fi
+31
View File
@@ -0,0 +1,31 @@
# Node / pnpm monorepo
node_modules/
.pnpm-store/
.turbo/
apps/web/dist/
apps/web/.tanstack/
apps/api/dist/
packages/*/dist/
# Data
data/
*.db
*.db-shm
*.db-wal
# Env
.env
.env.local
apps/web/.env.local
# IDE
.idea/
.vscode/
*.swp
# OS
.DS_Store
Thumbs.db
# Build
version.txt
+52
View File
@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1
ARG NODE_VERSION=22.23.0-bookworm-slim
FROM node:${NODE_VERSION} AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
COPY apps/web/package.json apps/web/
COPY apps/api/package.json apps/api/
COPY packages/ui/package.json packages/ui/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY apps/web apps/web
COPY apps/api apps/api
COPY packages/ui packages/ui
COPY packages/shared packages/shared
COPY packages/db packages/db
RUN pnpm turbo build --filter=web --filter=@authportal/api \
&& pnpm --filter @authportal/api deploy --prod /out \
&& cp -r apps/web/dist /out/static \
&& rm -rf /out/src /out/test /out/.turbo \
&& rm -rf /out/node_modules/@authportal/db/src /out/node_modules/@authportal/db/scripts /out/node_modules/@authportal/db/.turbo \
&& rm -rf /out/node_modules/@authportal/shared/src /out/node_modules/@authportal/shared/.turbo \
&& find /out/dist /out/node_modules/@authportal -type f \( -name '*.d.ts' -o -name '*.map' -o -name 'tsconfig*.json' -o -name 'vitest.config.ts' -o -name 'drizzle.config.ts' \) -delete
FROM node:${NODE_VERSION}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production \
STATIC_DIR=/app/static \
DATABASE_URL=sqlite:/data/app.db \
SERVER_PORT=8080
COPY --from=build /out ./
EXPOSE 8080
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
CMD ["node", "dist/server.js"]
+28
View File
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1
ARG NODE_VERSION=22.23.0-bookworm-slim
FROM node:${NODE_VERSION} AS test
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
COPY apps/web/package.json apps/web/
COPY apps/api/package.json apps/api/
COPY packages/ui/package.json packages/ui/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY apps/web apps/web
COPY apps/api apps/api
COPY packages/ui packages/ui
COPY packages/shared packages/shared
COPY packages/db packages/db
RUN pnpm turbo build --filter=@authportal/shared --filter=@authportal/db --filter=@authportal/api
CMD ["sh", "-c", "pnpm --filter @authportal/api test && pnpm --filter web test"]
+66
View File
@@ -0,0 +1,66 @@
# Auth Portal
Единый auth-портал для CFDM, VPS Tracker и EvoBGP (`auth.shnt.top`).
## Стек
- Monorepo: pnpm workspaces + turbo
- `apps/web` — Vite + React 19 + TanStack Router/Query + shadcn/ui + ReUI
- `apps/api` — Fastify 5 + JWT + SQLite (better-sqlite3 / Drizzle)
- `packages/ui``@authportal/ui` (shadcn primitives)
- `packages/shared` — Zod-контракты
- `packages/db` — схема и клиент SQLite
## Быстрый старт
```bash
pnpm install
cp .env.example .env
# ReUI PRO key уже в apps/web/.env.local (gitignored) — или скопируйте из другого проекта
pnpm --filter @authportal/api dev # :8080
pnpm --filter web dev # :5175
```
## Интеграция с VPS Tracker
См. [`docs/integrate-vps-tracker.md`](docs/integrate-vps-tracker.md) — SSO handoff (`return_to` + `#access_token`), общий `JWT_SECRET`, RBAC `vps:*`.
Корень:
```bash
pnpm dev # turbo: api + web
pnpm build
pnpm test
pnpm lint
```
## ReUI PRO
```bash
cd apps/web
# нужен REUI_LICENSE_KEY в .env.local
pnpm dlx shadcn@latest add @reui/auth-13 --yes
```
`components.json` → registry `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
## Docker / Gitea CI
- Workflow: `.gitea/workflows/docker.yml` (как CFDM)
- Secrets: `ACTIONS_PAT`, `GITEA_TOKEN`
- Образ: `git.shts.su/denozord/auth-portal`
```bash
docker compose up -d --build
```
## Структура
```
apps/web/src/ # SPA (routes, components/reui, reui-kit)
apps/api/src/ # Fastify
packages/ui/ # shadcn CLI output
packages/shared/ # contracts
packages/db/ # drizzle schema
.gitea/workflows/ # CI/CD
```
+1
View File
@@ -0,0 +1 @@
0.1.0
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@authportal/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup src/server.ts --format esm --dts",
"start": "node dist/server.js",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@authportal/db": "workspace:*",
"@authportal/shared": "workspace:*",
"@fastify/cors": "^11.0.1",
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@node-rs/argon2": "^2.0.2",
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.15.32",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
}
}
+136
View File
@@ -0,0 +1,136 @@
import type { FastifyInstance } from 'fastify'
import Fastify from 'fastify'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
import sensible from '@fastify/sensible'
import fjwt from '@fastify/jwt'
import fastifyStatic from '@fastify/static'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import {
APP_IDS,
allPermissionKeys,
} from '@authportal/shared'
import {
createDb,
getUserApps,
healthCheck,
migrateSchema,
setUserAccess,
users,
type AppDb,
type Sqlite,
} from '@authportal/db'
import { hash } from '@node-rs/argon2'
import { randomUUID } from 'node:crypto'
import type { AppConfig } from './config.js'
import { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js'
declare module 'fastify' {
interface FastifyInstance {
config: AppConfig
db: AppDb
sqlite: Sqlite
}
}
async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
const existing = app.db.select().from(users).all()
if (existing.length === 0) {
const now = new Date().toISOString()
const passwordHash = await hash(app.config.adminPassword)
const id = randomUUID()
app.db
.insert(users)
.values({
id,
email: app.config.adminEmail.toLowerCase(),
name: app.config.adminName,
passwordHash,
isAdmin: true,
disabled: false,
createdAt: now,
updatedAt: now,
})
.run()
setUserAccess(app.db, id, [...APP_IDS], allPermissionKeys())
app.log.info(`bootstrap admin created: ${app.config.adminEmail}`)
return
}
// Ensure existing admins have app tiles if access was never set
for (const user of existing) {
if (!user.isAdmin) continue
const apps = getUserApps(app.db, user.id)
if (apps.length === 0) {
setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys())
app.log.info(`granted full catalog access to admin ${user.email}`)
}
}
}
export async function buildApp(opts: {
config: AppConfig
databaseUrl?: string
}): Promise<FastifyInstance> {
const config = opts.config
const dbUrl = opts.databaseUrl ?? config.databaseUrl
const path = dbUrl.replace(/^sqlite:/, '')
if (path !== ':memory:') {
mkdirSync(dirname(resolve(path)), { recursive: true })
}
const { db, sqlite } = createDb(dbUrl)
migrateSchema(sqlite)
const app = Fastify({
logger: { level: config.logLevel },
})
app.decorate('config', config)
app.decorate('db', db)
app.decorate('sqlite', sqlite)
await app.register(sensible)
await app.register(cors, { origin: true, credentials: true })
await app.register(helmet, { contentSecurityPolicy: false })
await app.register(rateLimit, { max: 200, timeWindow: '1 minute' })
await app.register(fjwt, { secret: config.jwtSecret })
app.get('/health', async () => {
healthCheck(sqlite)
return { ok: true }
})
app.get('/ready', async () => {
healthCheck(sqlite)
return { ok: true }
})
app.get('/api/v1/health', async () => ({ ok: true, service: 'auth-portal' }))
await ensureBootstrapAdmin(app)
await app.register(authRoutes)
await app.register(adminRoutes)
if (config.staticDir && existsSync(config.staticDir)) {
await app.register(fastifyStatic, {
root: resolve(config.staticDir),
wildcard: false,
})
app.setNotFoundHandler((req, reply) => {
if (req.method === 'GET' && !req.url.startsWith('/api')) {
return reply.sendFile('index.html')
}
return reply
.status(404)
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
})
}
return app
}
+45
View File
@@ -0,0 +1,45 @@
import { z } from 'zod'
const boolFromEnv = (v: string | undefined, fallback: boolean) => {
if (v === undefined || v === '') return fallback
return v === '1' || v.toLowerCase() === 'true'
}
export const configSchema = z.object({
databaseUrl: z.string().default('sqlite:data/app.db'),
jwtSecret: z.string().min(8),
jwtTtlHours: z.coerce.number().positive().default(1),
refreshTtlDays: z.coerce.number().positive().default(14),
issuer: z.string().url().default('https://auth.shnt.top'),
adminEmail: z.string().email().default('[email protected]'),
adminPassword: z.string().default('admin'),
adminName: z.string().default('Admin'),
returnToAllowlist: z.string().default('.shnt.top,localhost'),
serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(),
logLevel: z.string().default('info'),
isProd: z.boolean(),
})
export type AppConfig = z.infer<typeof configSchema>
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const isProd = env.NODE_ENV === 'production'
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
return configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret,
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
refreshTtlDays: env.REFRESH_TTL_DAYS ?? 14,
issuer: env.ISSUER ?? 'https://auth.shnt.top',
adminEmail: env.ADMIN_EMAIL ?? '[email protected]',
adminPassword: env.ADMIN_PASSWORD ?? 'admin',
adminName: env.ADMIN_NAME ?? 'Admin',
returnToAllowlist: env.RETURN_TO_ALLOWLIST ?? '.shnt.top,localhost',
serverPort: env.SERVER_PORT ?? 8080,
staticDir: env.STATIC_DIR || undefined,
logLevel: env.LOG_LEVEL ?? 'info',
isProd: boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) || isProd,
})
}
+116
View File
@@ -0,0 +1,116 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import {
getUserApps,
getUserById,
getUserPermissions,
type UserRow,
} from '@authportal/db'
import type { AppId, MeResponse } from '@authportal/shared'
import { APP_IDS } from '@authportal/shared'
export type AuthUser = {
id: string
email: string
name: string
isAdmin: boolean
apps: AppId[]
permissions: string[]
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
iss: string
}
user: {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
iss: string
}
}
}
declare module 'fastify' {
interface FastifyRequest {
authUser?: AuthUser
}
}
function asAppIds(apps: string[]): AppId[] {
return apps.filter((a): a is AppId =>
(APP_IDS as readonly string[]).includes(a),
)
}
export function toMe(user: UserRow, apps: string[], permissions: string[]): MeResponse {
return {
id: user.id,
email: user.email,
name: user.name,
is_admin: user.isAdmin,
apps: asAppIds(apps),
permissions,
}
}
export function loadAuthUser(
request: FastifyRequest,
user: UserRow,
): AuthUser {
const apps = getUserApps(request.server.db, user.id)
const permissions = getUserPermissions(request.server.db, user.id)
return {
id: user.id,
email: user.email,
name: user.name,
isAdmin: user.isAdmin,
apps: asAppIds(apps),
permissions,
}
}
export async function requireAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
try {
await request.jwtVerify()
} catch {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
})
}
const sub = request.user.sub
const row = getUserById(request.server.db, sub)
if (!row || row.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
request.authUser = loadAuthUser(request, row)
}
export async function requireAdmin(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
await requireAuth(request, reply)
if (reply.sent) return
if (!request.authUser?.isAdmin) {
return reply.status(403).send({
error: { code: 'FORBIDDEN', message: 'Только для администраторов' },
})
}
}
+208
View File
@@ -0,0 +1,208 @@
import type { FastifyInstance } from 'fastify'
import { hash } from '@node-rs/argon2'
import {
createUser,
deleteUser,
getUserApps,
getUserByEmail,
getUserById,
getUserPermissions,
listUsers,
setUserAccess,
updateUser,
} from '@authportal/db'
import {
APP_IDS,
allPermissionKeys,
createUserRequestSchema,
patchUserRequestSchema,
putUserAccessRequestSchema,
type AdminUser,
type AppId,
} from '@authportal/shared'
import { requireAdmin } from '../plugins/auth-guards.js'
const allowedPermissions = new Set(allPermissionKeys())
function mapUser(
db: FastifyInstance['db'],
user: NonNullable<ReturnType<typeof getUserById>>,
): AdminUser {
const apps = getUserApps(db, user.id).filter((a): a is AppId =>
(APP_IDS as readonly string[]).includes(a),
)
const permissions = getUserPermissions(db, user.id)
return {
id: user.id,
email: user.email,
name: user.name,
is_admin: user.isAdmin,
disabled: user.disabled,
apps,
permissions,
created_at: user.createdAt,
updated_at: user.updatedAt,
}
}
function validateAccess(apps: string[], permissions: string[]): string | null {
for (const app of apps) {
if (!(APP_IDS as readonly string[]).includes(app)) {
return `Неизвестное приложение: ${app}`
}
}
for (const p of permissions) {
if (!allowedPermissions.has(p)) {
return `Неизвестное право: ${p}`
}
}
return null
}
export async function adminRoutes(app: FastifyInstance): Promise<void> {
app.addHook('onRequest', async (request, reply) => {
if (!request.url.startsWith('/api/v1/admin')) return
await requireAdmin(request, reply)
})
app.get('/api/v1/admin/users', async () => {
return listUsers(app.db).map((u) => mapUser(app.db, u))
})
app.get<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
const user = getUserById(app.db, request.params.id)
if (!user) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
return mapUser(app.db, user)
},
)
app.post('/api/v1/admin/users', async (request, reply) => {
const parsed = createUserRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const data = parsed.data
if (getUserByEmail(app.db, data.email)) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'Email уже занят' },
})
}
const accessError = validateAccess(data.apps, data.permissions)
if (accessError) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: accessError },
})
}
const passwordHash = await hash(data.password)
const user = createUser(app.db, {
email: data.email,
name: data.name,
passwordHash,
isAdmin: data.is_admin,
})
setUserAccess(app.db, user.id, data.apps, data.permissions)
return reply.status(201).send(mapUser(app.db, getUserById(app.db, user.id)!))
})
app.patch<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
const parsed = patchUserRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
if (parsed.data.email && parsed.data.email !== existing.email) {
const clash = getUserByEmail(app.db, parsed.data.email)
if (clash) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'Email уже занят' },
})
}
}
const passwordHash = parsed.data.password
? await hash(parsed.data.password)
: undefined
const updated = updateUser(app.db, request.params.id, {
email: parsed.data.email,
name: parsed.data.name,
passwordHash,
isAdmin: parsed.data.is_admin,
disabled: parsed.data.disabled,
})
return mapUser(app.db, updated!)
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
if (request.authUser?.id === request.params.id) {
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Нельзя удалить себя',
},
})
}
const ok = deleteUser(app.db, request.params.id)
if (!ok) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
return reply.status(204).send()
},
)
app.put<{ Params: { id: string } }>(
'/api/v1/admin/users/:id/access',
async (request, reply) => {
const parsed = putUserAccessRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const accessError = validateAccess(
parsed.data.apps,
parsed.data.permissions,
)
if (accessError) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: accessError },
})
}
setUserAccess(
app.db,
request.params.id,
parsed.data.apps,
parsed.data.permissions,
)
return mapUser(app.db, getUserById(app.db, request.params.id)!)
},
)
}
+129
View File
@@ -0,0 +1,129 @@
import type { FastifyInstance } from 'fastify'
import { hash, verify } from '@node-rs/argon2'
import { randomBytes } from 'node:crypto'
import {
createRefreshSession,
getUserApps,
getUserByEmail,
getUserPermissions,
revokeRefreshSession,
} from '@authportal/db'
import {
APPS,
PERMISSION_CATALOG,
loginRequestSchema,
type LoginResponse,
} from '@authportal/shared'
import { requireAuth, toMe } from '../plugins/auth-guards.js'
const REFRESH_COOKIE = 'refresh_token'
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post('/api/v1/auth/login', {
config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
handler: async (request, reply) => {
const parsed = loginRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const { email, password } = parsed.data
const user = getUserByEmail(app.db, email)
if (!user || user.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
})
}
const ok = await verify(user.passwordHash, password)
if (!ok) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
})
}
const apps = getUserApps(app.db, user.id)
const permissions = getUserPermissions(app.db, user.id)
const me = toMe(user, apps, permissions)
const expiresAt = new Date(
Date.now() + app.config.jwtTtlHours * 60 * 60 * 1000,
)
const accessToken = app.jwt.sign(
{
sub: user.id,
email: user.email,
name: user.name,
apps,
permissions,
is_admin: user.isAdmin,
iss: app.config.issuer,
},
{ expiresIn: `${app.config.jwtTtlHours}h` },
)
const refreshRaw = randomBytes(32).toString('hex')
const refreshExpires = new Date(
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
)
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires)
reply.header(
'Set-Cookie',
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
)
const body: LoginResponse = {
access_token: accessToken,
expires_at: expiresAt.toISOString(),
token_type: 'Bearer',
user: me,
}
return body
},
})
app.post(
'/api/v1/auth/logout',
{ onRequest: requireAuth },
async (request, reply) => {
const cookie = request.headers.cookie ?? ''
const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`))
if (match?.[1]) {
revokeRefreshSession(app.db, match[1])
}
reply.header(
'Set-Cookie',
`${REFRESH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
)
return { ok: true }
},
)
app.get(
'/api/v1/auth/me',
{ onRequest: requireAuth },
async (request) => {
const auth = request.authUser!
return {
id: auth.id,
email: auth.email,
name: auth.name,
is_admin: auth.isAdmin,
apps: auth.apps,
permissions: auth.permissions,
}
},
)
app.get(
'/api/v1/catalog',
{ onRequest: requireAuth },
async () => ({
apps: APPS,
permissions: PERMISSION_CATALOG,
}),
)
}
+40
View File
@@ -0,0 +1,40 @@
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { buildApp } from './app.js'
import { loadConfig } from './config.js'
for (const path of [
resolve(import.meta.dirname, '../../../.env'),
'.env',
'../.env',
]) {
if (!existsSync(path)) continue
const content = readFileSync(path, 'utf-8')
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eq = trimmed.indexOf('=')
if (eq === -1) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (!(key in process.env)) process.env[key] = value
}
break
}
const config = loadConfig()
const app = await buildApp({ config })
try {
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
app.log.info(`auth-portal listening on ${config.serverPort}`)
} catch (err) {
app.log.error(err)
process.exit(1)
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
passWithNoTests: true,
},
})
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": {
"Authorization": "Bearer ${REUI_LICENSE_KEY}"
}
}
},
"aliases": {
"components": "@/components",
"utils": "@authportal/ui/lib/utils",
"ui": "@authportal/ui/components",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle"
}
+42
View File
@@ -0,0 +1,42 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**', 'src/components/reui/**']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
rules: {
'react-hooks/set-state-in-effect': 'off',
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/components/ui/*'],
message: 'Импортируйте примитивы через @authportal/ui/components/*',
},
],
},
],
},
},
{
files: ['src/routes/**/*.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Auth Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
{
"name": "web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run --passWithNoTests",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@authportal/shared": "workspace:*",
"@authportal/ui": "workspace:*",
"@base-ui/react": "^1.5.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@tailwindcss/vite": "^4.3.1",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.15",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.6",
"@tanstack/router-vite-plugin": "^1.167.18",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.18.0",
"motion": "^12.42.2",
"next-themes": "^0.4.6",
"react": "^19.2.6",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.6",
"react-hook-form": "^7.79.0",
"sonner": "^2.0.7",
"tailwindcss": "^4.3.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"tw-animate-css": "^1.0.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.8"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+75
View File
@@ -0,0 +1,75 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { LayoutGridIcon, UsersIcon } from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { meQueryOptions } from '@/queries/auth'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@authportal/ui/components/sidebar'
function isActive(pathname: string, to: string, exact: boolean) {
if (exact) return pathname === to
return pathname === to || pathname.startsWith(`${to}/`)
}
export function AppSidebar() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const { data: me } = useQuery(meQueryOptions)
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Портал</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Приложения"
isActive={isActive(pathname, '/apps', true)}
render={<Link to="/apps" />}
>
<LayoutGridIcon className="size-4" />
<span>Приложения</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{me?.is_admin ? (
<SidebarGroup>
<SidebarGroupLabel>Админ</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
tooltip="Пользователи"
isActive={isActive(pathname, '/admin', false)}
render={<Link to="/admin" />}
>
<UsersIcon className="size-4" />
<span>Пользователи</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) : null}
</SidebarContent>
<SidebarFooter />
</Sidebar>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { APPS } from '@authportal/shared'
import {
CheckIcon,
ChevronsUpDownIcon,
KeyRoundIcon,
CloudIcon,
ServerIcon,
NetworkIcon,
} from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@authportal/ui/components/sidebar'
const PORTAL = {
id: 'portal',
name: 'Auth Portal',
subtitle: 'shnt.top',
url: '/',
icon: KeyRoundIcon,
}
const APP_ICONS = {
cfdm: CloudIcon,
vps: ServerIcon,
bgp: NetworkIcon,
} as const
export function AppSwitcher() {
const { isMobile } = useSidebar()
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<div
className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
aria-hidden
>
<KeyRoundIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{PORTAL.name}</span>
<span className="text-muted-foreground truncate text-xs">
{PORTAL.subtitle}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<div className="text-muted-foreground px-2 py-1.5 text-xs">
Приложения
</div>
<DropdownMenuItem disabled>
<KeyRoundIcon />
Auth Portal
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
{APPS.map((app) => {
const Icon = APP_ICONS[app.id]
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} target="_blank" rel="noreferrer" />}
>
<Icon />
{app.title}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -0,0 +1,208 @@
"use client"
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type ComponentPropsWithoutRef,
} from "react"
import { motion } from "motion/react"
import { cn } from "@authportal/ui/lib/utils"
export interface AnimatedGridPatternProps extends ComponentPropsWithoutRef<"svg"> {
width?: number
height?: number
x?: number
y?: number
strokeDasharray?: number
numSquares?: number
maxOpacity?: number
duration?: number
repeatDelay?: number
}
type Square = {
id: number
pos: [number, number]
iteration: number
}
// Deterministic 0..1 hash so cell placement stays stable across renders without
// Math.random, so SSR and client agree and the no-randomness gate stays green.
function pseudoRandom(seed: number): number {
const value = Math.sin(seed * 12.9898) * 43758.5453
return value - Math.floor(value)
}
export function AnimatedGridPattern({
width = 40,
height = 40,
x = -1,
y = -1,
strokeDasharray = 0,
numSquares = 30,
className,
maxOpacity = 0.1,
duration = 3,
repeatDelay = 1,
...props
}: AnimatedGridPatternProps) {
const id = useId()
const containerRef = useRef<SVGSVGElement | null>(null)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
const [squares, setSquares] = useState<Array<Square>>([])
const getPos = useCallback(
(seed: number): [number, number] => {
const cols = Math.max(1, Math.floor(dimensions.width / width))
const rows = Math.max(1, Math.floor(dimensions.height / height))
return [
Math.floor(pseudoRandom(seed) * cols),
Math.floor(pseudoRandom(seed + 0.5) * rows),
]
},
[dimensions.height, dimensions.width, height, width]
)
const generateSquares = useCallback(
(count: number) => {
return Array.from({ length: count }, (_, index) => ({
id: index,
pos: getPos(index + 1),
iteration: 0,
}))
},
[getPos]
)
const updateSquarePosition = useCallback(
(squareId: number) => {
setSquares((currentSquares) => {
const current = currentSquares[squareId]
if (!current || current.id !== squareId) {
return currentSquares
}
const nextSquares = currentSquares.slice()
const nextIteration = current.iteration + 1
nextSquares[squareId] = {
...current,
pos: getPos((squareId + 1) * 97 + nextIteration * 13),
iteration: nextIteration,
}
return nextSquares
})
},
[getPos]
)
useEffect(() => {
if (dimensions.width && dimensions.height) {
setSquares(generateSquares(numSquares))
}
}, [dimensions.width, dimensions.height, generateSquares, numSquares])
useEffect(() => {
const element = containerRef.current
if (!element) {
return
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setDimensions((currentDimensions) => {
const nextWidth = entry.contentRect.width
const nextHeight = entry.contentRect.height
if (
currentDimensions.width === nextWidth &&
currentDimensions.height === nextHeight
) {
return currentDimensions
}
return { width: nextWidth, height: nextHeight }
})
}
})
resizeObserver.observe(element)
return () => {
resizeObserver.disconnect()
}
}, [])
return (
<svg
ref={containerRef}
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full fill-gray-400/12 stroke-gray-400/9 dark:fill-gray-500/9 dark:stroke-gray-500/8",
className
)}
{...props}
>
<defs>
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
x={x}
y={y}
>
<path
d={`M.5 ${height}V.5H${width}`}
fill="none"
strokeDasharray={strokeDasharray}
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#${id})`} />
<svg x={x} y={y} className="overflow-visible">
{squares.map(({ pos: [squareX, squareY], id, iteration }, index) => (
<motion.rect
key={`${id}-${iteration}`}
initial={{ opacity: 0 }}
animate={{ opacity: maxOpacity }}
transition={{
duration,
repeat: 1,
delay: index * 0.1,
repeatType: "reverse",
repeatDelay,
}}
onAnimationComplete={() => updateSquarePosition(id)}
width={width - 1}
height={height - 1}
x={squareX * width + 1}
y={squareY * height + 1}
fill="currentColor"
strokeWidth="0"
/>
))}
</svg>
</svg>
)
}
export function AuthGridBackground() {
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<AnimatedGridPattern
numSquares={30}
maxOpacity={0.06}
duration={3}
repeatDelay={1}
className="inset-x-0 inset-y-[-30%] h-[200%] skew-y-12 mask-[radial-gradient(460px_circle_at_center,white,transparent)]"
/>
</div>
)
}
@@ -0,0 +1,35 @@
import { cn } from "@authportal/ui/lib/utils"
import { Item, ItemMedia } from "@authportal/ui/components/item"
export function AuthLogo({ className }: { className?: string }) {
return (
<Item
variant="outline"
className={cn(
"p-0",
"text-primary flex size-8 shrink-0 items-center justify-center",
className
)}
aria-hidden="true"
>
<ItemMedia variant="icon" className="size-auto">
<svg
width="50"
height="50"
viewBox="25.668 25.1352 49.6644 50"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-4"
>
<circle cx="70.634" cy="29.8334" r="4.69799" fill="currentColor" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M25.668 57.0144V29.8332C25.668 27.2386 27.7713 25.1352 30.366 25.1352C32.9606 25.1352 35.0639 27.2386 35.0639 29.8332V57.0144C35.0639 61.833 38.9702 65.7392 43.7888 65.7392H57.2116C62.0302 65.7392 65.9364 61.833 65.9364 57.0144V43.7258C65.9364 41.1312 68.0398 39.0278 70.6344 39.0278C73.229 39.0278 75.3324 41.1312 75.3324 43.7258V57.0144C75.3324 67.0222 67.2194 75.1352 57.2116 75.1352H43.7888C33.7809 75.1352 25.668 67.0222 25.668 57.0144Z"
fill="currentColor"
/>
</svg>
</ItemMedia>
</Item>
)
}
@@ -0,0 +1,108 @@
import { useState, type ComponentProps } from "react"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@authportal/ui/components/avatar"
import { Item, ItemMedia } from "@authportal/ui/components/item"
import { AuthGridBackground } from "./auth-grid-background"
import { AUTH18_TESTIMONIAL, AUTH18_TRUST_BRANDS } from "./data"
import { LoginForm } from "./login-form"
import { StarIcon } from "lucide-react"
type FormSubmitHandler = NonNullable<ComponentProps<"form">["onSubmit"]>
type FormSubmitEvent = Parameters<FormSubmitHandler>[0]
function SidebarBackground() {
return <AuthGridBackground />
}
function Sidebar() {
return (
<aside className="bg-muted/25 border-border/70 relative flex min-h-[34rem] overflow-hidden px-8 py-10 sm:px-12 lg:min-h-svh lg:border-r lg:px-14 lg:py-12">
{/* Sidebar */}
<SidebarBackground />
<div className="relative z-10 flex min-h-full w-full flex-col justify-between gap-10">
<div className="mx-auto flex max-w-md flex-1 flex-col items-center justify-center text-center">
<div className="text-primary flex items-center gap-1">
{Array.from({ length: AUTH18_TESTIMONIAL.stars }).map((_, index) => (
<StarIcon aria-hidden="true" className="size-4 fill-current" key={index} />
))}
</div>
<blockquote className="mt-6 max-w-[28rem] text-[1.625rem] leading-[1.34] font-semibold text-balance">
{AUTH18_TESTIMONIAL.quote}
</blockquote>
<div className="mt-5 flex w-full justify-center">
<div className="flex min-w-0 items-center gap-1.5">
<Avatar className="size-8">
<AvatarImage
src={AUTH18_TESTIMONIAL.avatar}
alt={AUTH18_TESTIMONIAL.name}
/>
<AvatarFallback>{AUTH18_TESTIMONIAL.fallback}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col items-start gap-0 text-left">
<div className="text-sm font-medium">
{AUTH18_TESTIMONIAL.name}
</div>
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
<span>{AUTH18_TESTIMONIAL.role}</span>
<span
aria-hidden="true"
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
/>
<span>{AUTH18_TESTIMONIAL.company}</span>
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="text-foreground text-sm font-medium">
Trusted by leading teams
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
{AUTH18_TRUST_BRANDS.map((brand) => (
<Item
key={brand.id}
className="text-foreground/90 flex w-auto items-center border-0 p-0"
>
<ItemMedia variant="icon" className="size-auto">
{brand.logo}
</ItemMedia>
</Item>
))}
</div>
</div>
</div>
</aside>
)
}
export function Auth() {
const [showPassword, setShowPassword] = useState(false)
function handleSubmit(event: FormSubmitEvent) {
event.preventDefault()
}
return (
<div className="w-full lg:grid lg:min-h-svh lg:grid-cols-[600px_minmax(0,1fr)]">
{/* Sidebar */}
<Sidebar />
{/* Form */}
<LoginForm
showPassword={showPassword}
onTogglePassword={() => setShowPassword((current) => !current)}
onSubmit={handleSubmit}
/>
</div>
)
}
@@ -0,0 +1,118 @@
import type { ReactNode } from "react"
import { Apple } from "@authportal/ui/components/svgs/apple"
import { AppleDark } from "@authportal/ui/components/svgs/appleDark"
import { Google } from "@authportal/ui/components/svgs/google"
import { OpenaiWordmarkDark } from "@authportal/ui/components/svgs/openaiWordmarkDark"
import { OpenaiWordmarkLight } from "@authportal/ui/components/svgs/openaiWordmarkLight"
import { SlackWordmark } from "@authportal/ui/components/svgs/slackWordmark"
import { StripeWordmark } from "@authportal/ui/components/svgs/stripeWordmark"
import { SupabaseWordmarkDark } from "@authportal/ui/components/svgs/supabaseWordmarkDark"
import { SupabaseWordmarkLight } from "@authportal/ui/components/svgs/supabaseWordmarkLight"
export type AuthProvider = {
id: string
label: string
logo: ReactNode
}
export type TrustBrand = {
id: string
logo: ReactNode
}
export type Testimonial = {
quote: string
name: string
role: string
company: string
avatar: string
fallback: string
stars: number
}
function ThemeLogo({ light, dark }: { light: ReactNode; dark: ReactNode }) {
return (
<>
<span aria-hidden="true" className="dark:hidden">
{light}
</span>
<span aria-hidden="true" className="hidden dark:block">
{dark}
</span>
</>
)
}
const providerLogoClassName = "size-4 shrink-0"
export const AUTH18_PROVIDERS: AuthProvider[] = [
{
id: "google",
label: "Google",
logo: <Google className={providerLogoClassName} aria-hidden="true" />,
},
{
id: "apple",
label: "Apple",
logo: (
<ThemeLogo
light={<Apple className={providerLogoClassName} aria-hidden="true" />}
dark={
<AppleDark className={providerLogoClassName} aria-hidden="true" />
}
/>
),
},
]
export const AUTH18_TRUST_BRANDS: TrustBrand[] = [
{
id: "openai",
logo: (
<ThemeLogo
light={
<OpenaiWordmarkLight aria-hidden="true" className="h-4 w-auto" />
}
dark={<OpenaiWordmarkDark aria-hidden="true" className="h-4 w-auto" />}
/>
),
},
{
id: "stripe",
logo: <StripeWordmark aria-hidden="true" className="h-4 w-auto" />,
},
{
id: "supabase",
logo: (
<ThemeLogo
light={
<SupabaseWordmarkLight aria-hidden="true" className="h-4 w-auto" />
}
dark={
<SupabaseWordmarkDark aria-hidden="true" className="h-4 w-auto" />
}
/>
),
},
{
id: "slack",
logo: (
<SlackWordmark
aria-hidden="true"
className="text-foreground h-4 w-auto"
/>
),
},
]
export const AUTH18_TESTIMONIAL: Testimonial = {
quote: "The best login pages disappear. This one already feels fast.",
name: "Sean Bold",
role: "Co-founder",
company: "ReUI",
avatar:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
fallback: "SB",
stars: 5,
}
@@ -0,0 +1,140 @@
import { type ComponentProps } from "react"
import { Button } from "@authportal/ui/components/button"
import { Field, FieldGroup, FieldLabel } from "@authportal/ui/components/field"
import { Input } from "@authportal/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@authportal/ui/components/input-group"
import { Separator } from "@authportal/ui/components/separator"
import { AuthLogo } from "./auth-logo"
import { AUTH18_PROVIDERS } from "./data"
import { EyeOffIcon, EyeIcon } from "lucide-react"
type FormSubmitHandler = NonNullable<ComponentProps<"form">["onSubmit"]>
type FormSubmitEvent = Parameters<FormSubmitHandler>[0]
export function LoginForm({
showPassword,
onTogglePassword,
onSubmit,
}: {
showPassword: boolean
onTogglePassword: () => void
onSubmit: (event: FormSubmitEvent) => void
}) {
return (
<section className="flex min-w-0 flex-col justify-between py-4 sm:py-6 lg:py-8">
{/* Heading */}
<div className="flex flex-1 flex-col justify-center">
<div className="mx-auto flex w-full max-w-90 flex-col gap-6">
<div className="flex flex-col items-center gap-3 text-center">
<AuthLogo />
<div className="flex flex-col gap-1">
<h1 className="text-xl font-semibold tracking-tight">
Sign in to ReUI
</h1>
<p className="text-muted-foreground text-sm">Welcome back.</p>
</div>
</div>
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<FieldGroup className="gap-3.5">
<Field className="gap-2">
<FieldLabel htmlFor="auth-18-identifier">
Email or username
</FieldLabel>
<Input
id="auth-18-identifier"
type="text"
autoComplete="username"
placeholder="Email or username"
className="bg-background"
/>
</Field>
<Field className="gap-2">
<div className="flex items-center justify-between gap-3">
<FieldLabel htmlFor="auth-18-password">
Password
</FieldLabel>
<Button
type="button"
variant="link"
className="text-muted-foreground h-auto p-0 text-xs font-normal"
>
Forgot password?
</Button>
</div>
<InputGroup className="bg-background w-full">
<InputGroupInput
id="auth-18-password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder="Enter your password"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
aria-label={
showPassword ? "Hide password" : "Show password"
}
aria-pressed={showPassword}
onClick={onTogglePassword}
>
{showPassword ? (
<EyeOffIcon aria-hidden="true" className="size-4" />
) : (
<EyeIcon aria-hidden="true" className="size-4" />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
<Button type="submit" className="w-full">
Sign in
</Button>
</form>
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">
Or continue with
</span>
<Separator className="flex-1" />
</div>
<div className="grid gap-3 sm:grid-cols-2">
{AUTH18_PROVIDERS.map((provider) => (
<Button
key={provider.id}
type="button"
variant="outline"
className="w-full"
>
{provider.logo}
{provider.label}
</Button>
))}
</div>
<p className="text-muted-foreground text-center text-sm">
Need an account?{" "}
<Button type="button" variant="link" className="h-auto p-0">
Sign up
</Button>
</p>
</div>
</div>
</section>
)
}
@@ -0,0 +1,9 @@
import { Auth } from "./components/auth"
export function Page() {
return (
<div className="bg-background min-h-svh w-full">
<Auth />
</div>
)
}
@@ -0,0 +1,77 @@
export interface PermissionItem {
id: string
title: string
description: string
defaultChecked: boolean
}
// ── Data ──
export const permissions: PermissionItem[] = [
{
id: "workspace-settings",
title: "Workspace Settings",
description:
"Review workspace details, team defaults, and operational preferences.",
defaultChecked: true,
},
{
id: "billing-management",
title: "Billing Management",
description: "Access plan details, invoices, and subscription adjustments.",
defaultChecked: false,
},
{
id: "integration-setup",
title: "Integration Setup",
description: "Configure apps, credentials, and automation entry points.",
defaultChecked: true,
},
{
id: "permissions-control",
title: "Permissions Control",
description: "Grant, revoke, and review access scopes for collaborators.",
defaultChecked: false,
},
{
id: "map-creation",
title: "Map Creation",
description: "Create new workspace maps and maintain location structure.",
defaultChecked: false,
},
{
id: "data-export",
title: "Data Export",
description:
"Download structured workspace reports for analysis and audits.",
defaultChecked: true,
},
{
id: "user-roles",
title: "User Roles",
description:
"Edit role assignments and keep team responsibility lines clear.",
defaultChecked: true,
},
{
id: "security-settings",
title: "Security Settings",
description:
"Adjust workspace protection controls and policy requirements.",
defaultChecked: true,
},
{
id: "insights-access",
title: "Insights Access",
description:
"View performance dashboards, usage trends, and reporting panels.",
defaultChecked: false,
},
{
id: "merchant-list",
title: "Merchant List",
description:
"Maintain merchant records and workspace-linked account mappings.",
defaultChecked: false,
},
]
@@ -0,0 +1,108 @@
import { useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { Button } from "@authportal/ui/components/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from "@authportal/ui/components/item"
import { Switch } from "@authportal/ui/components/switch"
import { permissions } from "./data"
// ── Permission Card ──
function PermissionCard({
checked,
description,
id,
onCheckedChange,
title,
}: {
checked: boolean
description: string
id: string
onCheckedChange: (checked: boolean) => void
title: string
}) {
return (
<Item variant="outline" className="items-start gap-3">
{/* Content */}
<ItemContent className="gap-1.5">
<ItemTitle>{title}</ItemTitle>
<ItemDescription>{description}</ItemDescription>
</ItemContent>
{/* Actions */}
<ItemActions className="ml-auto self-center">
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
aria-label={title}
id={id}
/>
</ItemActions>
</Item>
)
}
export function RolePermissions() {
const [values, setValues] = useState<Record<string, boolean>>(() =>
Object.fromEntries(
permissions.map((permission) => [
permission.id,
permission.defaultChecked,
])
)
)
return (
<Frame className="w-full max-w-5xl">
{/* Header */}
<FrameHeader className="flex-row items-center justify-between gap-5">
<div className="space-y-px">
<FrameTitle>Role Permissions for Project Manager</FrameTitle>
<FrameDescription>
Control the workspace capabilities this role can manage.
</FrameDescription>
</div>
<Button aria-label="Updates (new)">
Permission
<Badge variant="success" size="xs" aria-hidden="true">
New
</Badge>
</Button>
</FrameHeader>
{/* Content */}
<FramePanel className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{permissions.map((permission) => (
<PermissionCard
key={permission.id}
id={permission.id}
title={permission.title}
description={permission.description}
checked={values[permission.id]}
onCheckedChange={(checked) =>
setValues((current) => ({
...current,
[permission.id]: checked,
}))
}
/>
))}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,9 @@
import { RolePermissions } from "./components/role-permissions"
export function Page() {
return (
<div className="flex min-h-svh w-full max-w-4xl items-start justify-center p-4 sm:p-8 md:p-12">
<RolePermissions />
</div>
)
}
@@ -0,0 +1,165 @@
export type TicketStatus = "open" | "waiting" | "resolved"
export type TicketPriority = "low" | "medium" | "high" | "urgent"
export type TicketSource = "portal" | "inbox" | "api"
export type TicketCategory = "access" | "security" | "billing" | "workflow"
export type TicketSelectOption<TValue extends string = string> = {
value: TValue
label: string
description?: string
}
export type SupportMember = {
id: string
name: string
role: string
src: string
initials: string
}
export type TicketDetailTip = {
text: string
}
export type TicketDetailsValue = {
dueDate: string
status: TicketStatus
slaMet: boolean
priority: TicketPriority
source: TicketSource
channel: string
requestForm: string
category: TicketCategory
notifyRequester: boolean
tags: string[]
collaboratorIds: string[]
}
export const STATUS_OPTIONS: TicketSelectOption<TicketStatus>[] = [
{
value: "open",
label: "Open",
description: "Active and ready for the next response.",
},
{
value: "waiting",
label: "Waiting",
description: "Paused until requester or vendor input arrives.",
},
{
value: "resolved",
label: "Resolved",
description: "Completed and ready for closure.",
},
]
export const PRIORITY_OPTIONS: TicketSelectOption<TicketPriority>[] = [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
]
export const SOURCE_OPTIONS: TicketSelectOption<TicketSource>[] = [
{ value: "portal", label: "Customer Portal" },
{ value: "inbox", label: "Shared Inbox" },
{ value: "api", label: "API Intake" },
]
export const REQUEST_FORM_OPTIONS: TicketSelectOption[] = [
{
value: "workspace-access",
label: "Workspace Access",
description: "Provisioning, group access, and app authorization.",
},
{
value: "vendor-review",
label: "Vendor Review",
description: "Security and procurement review for new tools.",
},
{
value: "billing-exception",
label: "Billing Exception",
description: "Invoice changes, credits, and payment routing.",
},
{
value: "automation-change",
label: "Automation Change",
description: "Workflow updates owned by operations.",
},
]
export const CATEGORY_OPTIONS: TicketSelectOption<TicketCategory>[] = [
{ value: "access", label: "Access Request" },
{ value: "security", label: "Security Review" },
{ value: "billing", label: "Billing Support" },
{ value: "workflow", label: "Workflow Change" },
]
export const TAG_OPTIONS = [
"Feature",
"VIP",
"Automation",
"Security",
"Renewal",
"Finance",
]
export const COLLABORATORS: SupportMember[] = [
{
id: "mira",
name: "Mira Stone",
role: "Identity owner",
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
initials: "MS",
},
{
id: "leo",
name: "Leo Grant",
role: "Support lead",
src: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
initials: "LG",
},
{
id: "nora",
name: "Nora Vale",
role: "Workflow admin",
src: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
initials: "NV",
},
{
id: "theo",
name: "Theo Park",
role: "Security reviewer",
src: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
initials: "TP",
},
]
export const DEFAULT_TICKET_DETAILS: TicketDetailsValue = {
dueDate: "2026-04-30",
status: "open",
slaMet: true,
priority: "medium",
source: "portal",
channel: "identity-access",
requestForm: "workspace-access",
category: "access",
notifyRequester: true,
tags: ["Feature", "Security"],
collaboratorIds: ["mira", "leo"],
}
export const TICKET_TIMESTAMPS = {
createdAt: "Jan 21, 2026 at 10:16 PM",
updatedAt: "Just now",
}
export const TICKET_DETAIL_TIPS: TicketDetailTip[] = [
{
text: "Press Enter while editing a text value to save the row.",
},
{
text: "Use row actions for quick updates without leaving the queue.",
},
]
@@ -0,0 +1,224 @@
"use client"
import { useEffect, useRef, type ReactNode } from "react"
import { cn } from "@authportal/ui/lib/utils"
import { Button } from "@authportal/ui/components/button"
import { Field, FieldTitle } from "@authportal/ui/components/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
} from "@authportal/ui/components/input-group"
import { Item, ItemMedia } from "@authportal/ui/components/item"
import { Spinner } from "@authportal/ui/components/spinner"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@authportal/ui/components/tooltip"
import { InfoIcon, PencilIcon, XIcon, CheckIcon } from "lucide-react"
interface EditableDetailRowProps {
label: string
hint?: string
editing?: boolean
display: ReactNode
renderEdit?: (active: boolean) => ReactNode
align?: "center" | "start"
actionsDisabled?: boolean
saving?: boolean
onEdit?: () => void
onCancel?: () => void
onSave?: () => void
}
function RowHint({ label, children }: { label: string; children: string }) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground -my-1 shrink-0"
aria-label={label}
/>
}
>
<InfoIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
{children}
</TooltipContent>
</Tooltip>
)
}
export function EditableDetailRow({
label,
hint,
editing = false,
display,
renderEdit,
align = "center",
actionsDisabled = false,
saving = false,
onEdit,
onCancel,
onSave,
}: EditableDetailRowProps) {
const editable = Boolean(renderEdit && onEdit && onCancel && onSave)
const controlsDisabled = actionsDisabled || saving
const controlActive = !controlsDisabled
const editActionsDisabled = !editing || controlsDisabled
const editRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!editing) {
return
}
const frame = requestAnimationFrame(() => {
const control = editRef.current?.querySelector<HTMLElement>(
[
"[data-slot='input-group-control']:not(:disabled)",
"[data-slot='combobox-chip-input']:not(:disabled)",
"button:not(:disabled)",
"input:not(:disabled)",
].join(",")
)
control?.focus({ preventScroll: true })
})
return () => cancelAnimationFrame(frame)
}, [editing])
return (
<Field
className={cn(
"group/row grid gap-x-2 gap-y-0.5 px-4 py-0.5 sm:grid-cols-[minmax(7.75rem,0.5fr)_minmax(0,1.5fr)] sm:gap-x-4",
align === "start" ? "sm:items-start" : "sm:items-center"
)}
>
<FieldTitle
className={cn(
"text-muted-foreground flex min-w-0 items-center gap-1 text-sm font-normal",
align === "start" && "sm:min-h-8"
)}
>
<span className="min-w-0 truncate">{label}</span>
{hint ? <RowHint label={`${label} info`}>{hint}</RowHint> : null}
</FieldTitle>
{renderEdit ? (
<div
className={cn(
"relative col-start-1 row-start-2 min-h-8 min-w-0 sm:col-start-2 sm:row-start-1",
align === "start" ? "items-start" : "items-center"
)}
>
<button
type="button"
disabled={!editable || controlsDisabled || editing}
aria-label={`Edit ${label}`}
aria-hidden={editing}
className={cn(
"group/value flex w-full min-w-0 rounded-md border border-transparent px-2.5 text-left transition-[opacity,color,background-color] duration-150 outline-none",
align === "start"
? "h-auto min-h-8 items-start py-1"
: "h-8 items-center",
editable &&
"hover:bg-muted/40 active:bg-muted/40 sm:group-hover/row:bg-muted/40 focus-visible:border-transparent! focus-visible:ring-0! focus-visible:outline-none!",
controlsDisabled && "pointer-events-none",
editing
? "pointer-events-none absolute inset-x-0 top-0 opacity-0"
: "relative opacity-100"
)}
onClick={onEdit}
>
<span
className={cn(
"flex min-w-0",
align === "start" ? "items-start" : "items-center"
)}
>
{display}
</span>
{editable ? (
<Item
render={<span />}
className={cn(
"p-0",
"text-muted-foreground ml-1.5 flex size-5 shrink-0 items-center justify-center opacity-100 transition-opacity sm:opacity-0 sm:group-hover/row:opacity-100 sm:group-focus-visible/value:opacity-100",
controlsDisabled && "invisible opacity-0 sm:opacity-0"
)}
>
<ItemMedia variant="icon" className="size-auto">
<PencilIcon className="size-3.5" aria-hidden="true" />
</ItemMedia>
</Item>
) : null}
</button>
<div
ref={editRef}
aria-hidden={!editing}
inert={!editing ? true : undefined}
className={cn(
"min-w-0 transition-opacity duration-150",
editing
? "relative opacity-100"
: "pointer-events-none absolute inset-x-0 top-0 opacity-0"
)}
>
<InputGroup
className={cn(
"has-[[data-slot=input-group-control]:focus-visible]:border-input! box-border w-full has-[[data-slot=input-group-control]:focus-visible]:shadow-none! has-[[data-slot=input-group-control]:focus-visible]:ring-0!",
align === "start" ? "h-auto! min-h-8! items-start" : "h-8"
)}
>
{renderEdit(controlActive)}
{editable ? (
<InputGroupAddon
align="inline-end"
className={cn(
"gap-1 pr-2",
align === "start" && "self-start pt-1"
)}
>
<InputGroupButton
size="icon-xs"
aria-label={`Discard ${label}`}
disabled={editActionsDisabled}
onClick={onCancel}
>
<XIcon className="size-4" aria-hidden="true" />
</InputGroupButton>
<InputGroupButton
size="icon-xs"
aria-label={saving ? `Saving ${label}` : `Save ${label}`}
disabled={editActionsDisabled}
onClick={onSave}
>
{saving ? (
<Spinner className="size-3.5" />
) : (
<CheckIcon className="size-4" aria-hidden="true" />
)}
</InputGroupButton>
</InputGroupAddon>
) : null}
</InputGroup>
</div>
</div>
) : (
<div className="col-start-1 row-start-2 flex min-h-8 min-w-0 items-center px-2.5 sm:col-start-2 sm:row-start-1">
{display}
</div>
)}
</Field>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
import { TicketDetailsSheet } from "./components/ticket-details-sheet"
export function Page() {
return (
<main
className="flex min-h-svh w-full items-center justify-center p-6 sm:p-10 md:p-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Editable ticket details sheet
</h1>
<TicketDetailsSheet />
</main>
)
}
@@ -0,0 +1,98 @@
import { Button } from "@authportal/ui/components/button"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@authportal/ui/components/select"
import { BULK_ROLE_OPTIONS, type MemberRole } from "./data"
import { PencilIcon, SendIcon, PauseCircleIcon } from "lucide-react"
interface BulkActionBarProps {
selectedCount: number
roleValue: MemberRole
onRoleChange: (value: MemberRole) => void
onChangeRole: () => void
onResendInvite: () => void
onDeactivate: () => void
onClear: () => void
}
export function BulkActionBar({
selectedCount,
roleValue,
onRoleChange,
onChangeRole,
onResendInvite,
onDeactivate,
onClear,
}: BulkActionBarProps) {
return (
<div className="bg-muted/25 flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py) lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">
{selectedCount} member{selectedCount === 1 ? "" : "s"} selected
</span>
<span className="text-muted-foreground text-xs">
Change role, resend invites, or revoke access in one step.
</span>
</div>
{/* Actions */}
<div className="flex flex-wrap items-center gap-2">
<Select
value={roleValue}
onValueChange={(value) => {
if (!value) return
onRoleChange(value as MemberRole)
}}
items={BULK_ROLE_OPTIONS}
>
<SelectTrigger size="sm" className="w-[164px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{BULK_ROLE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
onClick={onChangeRole}
>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Change role
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={onResendInvite}
>
<SendIcon data-icon="inline-start" aria-hidden="true" />
Resend invite
</Button>
<Button type="button" size="sm" onClick={onDeactivate}>
<PauseCircleIcon data-icon="inline-start" aria-hidden="true" />
Deactivate
</Button>
<Button type="button" size="sm" variant="ghost" onClick={onClear}>
Clear
</Button>
</div>
</div>
)
}
@@ -0,0 +1,540 @@
import { memo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import {
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from "@/components/reui/data-grid/data-grid-table"
import { Row, type ColumnDef } from "@tanstack/react-table"
import { toast } from "sonner"
import { cn } from "@authportal/ui/lib/utils"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@authportal/ui/components/alert-dialog"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@authportal/ui/components/avatar"
import { Button } from "@authportal/ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@authportal/ui/components/dropdown-menu"
import { Skeleton } from "@authportal/ui/components/skeleton"
import {
MemberRole,
MemberStatus,
TEAM_LABELS,
type AuthMethod,
type IMember,
type TeamLabel,
type TwoFactor,
} from "./data"
import { ShieldCheckIcon, LockIcon, TriangleAlertIcon, KeyRoundIcon, MoreHorizontalIcon, EyeIcon, PencilIcon, SendIcon, PauseCircleIcon, Trash2Icon } from "lucide-react"
// ── Team tag colors (light + dark) ──
const teamBadgeClass: Record<TeamLabel, string> = {
Engineering:
"bg-indigo-100 text-indigo-800 dark:bg-indigo-950/50 dark:text-indigo-300",
Product:
"bg-violet-100 text-violet-800 dark:bg-violet-950/50 dark:text-violet-300",
Design: "bg-sky-100 text-sky-800 dark:bg-sky-950/50 dark:text-sky-300",
Sales:
"bg-emerald-100 text-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300",
Marketing:
"bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300",
"Customer Success":
"bg-cyan-100 text-cyan-800 dark:bg-cyan-950/50 dark:text-cyan-300",
Finance:
"bg-yellow-100 text-yellow-800 dark:bg-yellow-950/50 dark:text-yellow-300",
"IT/Security":
"bg-rose-100 text-rose-800 dark:bg-rose-950/50 dark:text-rose-300",
}
function getTeamClasses(tag: string): string {
if (TEAM_LABELS.includes(tag as TeamLabel)) {
return teamBadgeClass[tag as TeamLabel]
}
return "bg-muted text-muted-foreground"
}
export const TeamTags = memo(function TeamTags({
teams,
}: {
teams: TeamLabel[]
}) {
return (
<div className="flex flex-wrap items-center gap-1">
{teams.map((team) => (
<Badge
key={team}
variant="secondary"
className={cn("border-0", getTeamClasses(team))}
>
{team}
</Badge>
))}
</div>
)
})
// ── Status badge ──
const statusConfig: Record<MemberStatus, { dot: string }> = {
Active: { dot: "bg-emerald-500" },
Invited: { dot: "bg-amber-500" },
Suspended: { dot: "bg-red-500" },
Deactivated: { dot: "bg-muted-foreground" },
}
export function StatusBadge({ status }: { status: MemberStatus }) {
return (
<Badge variant="outline">
<span
className={cn(
"size-1.5 shrink-0 rounded-full!",
statusConfig[status].dot
)}
/>
{status}
</Badge>
)
}
// ── Role badge ──
const roleConfig: Record<
MemberRole,
{ variant: React.ComponentProps<typeof Badge>["variant"] }
> = {
Owner: { variant: "primary-outline" },
Admin: { variant: "info-outline" },
Member: { variant: "secondary" },
Billing: { variant: "warning-outline" },
Guest: { variant: "outline" },
"Support Agent": { variant: "secondary" },
}
export function RoleBadge({ role }: { role: MemberRole }) {
return <Badge variant={roleConfig[role].variant}>{role}</Badge>
}
// ── Member cell (same layout as the donor ContactCell) ──
const MemberCell = memo(function MemberCell({ row }: { row: Row<IMember> }) {
const o = row.original
return (
<div className="flex items-center gap-2">
<Avatar className="size-8 shrink-0">
<AvatarImage src={o.avatar} alt="" />
<AvatarFallback>
{o.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="text-foreground line-clamp-1 font-medium">{o.name}</div>
<div
className="text-muted-foreground line-clamp-1 text-xs"
title={o.email}
>
{o.email}
</div>
</div>
</div>
)
})
// ── Auth cell (SSO / 2FA badges) ──
const authBadgeVariant: Record<
AuthMethod,
React.ComponentProps<typeof Badge>["variant"]
> = {
SSO: "success-outline",
Password: "warning-outline",
}
const twoFactorBadgeVariant: Record<
TwoFactor,
React.ComponentProps<typeof Badge>["variant"]
> = {
Authenticator: "info-outline",
Passkey: "success-outline",
"Security key": "success-outline",
SMS: "warning-outline",
}
function AuthMethodIcon({ auth }: { auth: AuthMethod }) {
if (auth === "SSO") {
return (
<ShieldCheckIcon aria-hidden="true" />
)
}
return (
<LockIcon aria-hidden="true" />
)
}
function TwoFactorIcon({ factor }: { factor: TwoFactor | null }) {
if (!factor) {
return (
<TriangleAlertIcon aria-hidden="true" />
)
}
return (
<KeyRoundIcon aria-hidden="true" />
)
}
function AuthCell({ row }: { row: Row<IMember> }) {
const { auth, ssoProvider, twoFactor } = row.original
return (
<div className="flex flex-wrap items-center gap-1">
<Badge variant={authBadgeVariant[auth]}>
<AuthMethodIcon auth={auth} />
{auth === "SSO" ? (ssoProvider ?? "SSO") : "Password"}
</Badge>
<Badge
variant={
twoFactor ? twoFactorBadgeVariant[twoFactor] : "destructive-outline"
}
>
<TwoFactorIcon factor={twoFactor} />
{twoFactor ?? "No 2FA"}
</Badge>
</div>
)
}
// ── Actions cell ──
export function ActionsCell({
row,
onEditRole,
onView,
}: {
row: Row<IMember>
onEditRole: (member: IMember) => void
onView: (member: IMember) => void
}) {
const [removeOpen, setRemoveOpen] = useState(false)
const member = row.original
const handleRemoveConfirm = () => {
setRemoveOpen(false)
toast.success("Member removed", {
description: `${member.name} loses access to Acme Cloud immediately.`,
})
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label="Row actions"
/>
}
>
<MoreHorizontalIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onView(member)}>
<EyeIcon className="size-4" aria-hidden="true" />
View Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditRole(member)}>
<PencilIcon className="size-4" aria-hidden="true" />
Edit Role
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.info("Invite resent", {
description: `New link sent to ${member.email}. Expires in 7 days.`,
})
}
>
<SendIcon className="size-4" aria-hidden="true" />
Resend Invite
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.message("Member suspended", {
description: `${member.name} can no longer sign in until reinstated.`,
})
}
>
<PauseCircleIcon className="size-4" aria-hidden="true" />
Suspend
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setRemoveOpen(true)}
>
<Trash2Icon className="size-4" aria-hidden="true" />
Remove
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={removeOpen} onOpenChange={setRemoveOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
This revokes access for{""}
<span className="text-foreground font-medium">{member.name}</span>
{""}
and frees their seat. Connect your API to persist changes.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleRemoveConfirm}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
// ── Column definitions ──
interface ColumnHandlers {
onEditRole: (member: IMember) => void
onView: (member: IMember) => void
}
export function createMemberColumns({
onEditRole,
onView,
}: ColumnHandlers): ColumnDef<IMember>[] {
return [
{
id: "select",
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
enableSorting: false,
size: 40,
enableResizing: false,
enableHiding: false,
meta: {
skeleton: <Skeleton className="mx-auto size-5" />,
headerClassName:
"[--data-grid-header-cell-ps:var(--frame-panel-header-px)]",
cellClassName: "[--data-grid-body-cell-ps:var(--frame-panel-px)]",
},
},
{
accessorKey: "name",
id: "name",
header: ({ column }) => (
<DataGridColumnHeader
title="Member"
visibility={true}
column={column}
/>
),
cell: ({ row }) => <MemberCell row={row} />,
enableSorting: true,
enableHiding: false,
enableResizing: true,
minSize: 220,
meta: {
headerTitle: "Member",
autoSize: true,
skeleton: (
<div className="flex min-w-0 items-center gap-2">
<Skeleton className="size-8 shrink-0 rounded-full" />
<div className="flex min-w-0 flex-col gap-0.5">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3 w-40" />
</div>
</div>
),
},
},
{
accessorKey: "role",
id: "role",
header: ({ column }) => (
<DataGridColumnHeader title="Role" visibility={true} column={column} />
),
cell: ({ row }) => <RoleBadge role={row.original.role} />,
size: 120,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Role",
skeleton: <Skeleton className="h-6 w-16 rounded-full" />,
},
},
{
accessorKey: "teams",
id: "teams",
header: ({ column }) => (
<DataGridColumnHeader title="Teams" visibility={true} column={column} />
),
cell: ({ row }) => <TeamTags teams={row.original.teams} />,
size: 200,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Teams",
skeleton: (
<div className="flex flex-wrap items-center gap-1">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
),
},
},
{
accessorKey: "status",
id: "status",
header: ({ column }) => (
<DataGridColumnHeader
title="Status"
visibility={true}
column={column}
/>
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
size: 120,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Status",
skeleton: <Skeleton className="h-6 w-24 rounded-full" />,
},
},
{
accessorKey: "auth",
id: "auth",
header: ({ column }) => (
<DataGridColumnHeader title="Auth" visibility={true} column={column} />
),
cell: ({ row }) => <AuthCell row={row} />,
size: 220,
enableSorting: false,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Auth",
skeleton: (
<div className="flex items-center gap-1">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
</div>
),
},
},
{
accessorKey: "lastActiveIso",
id: "lastActive",
header: ({ column }) => (
<DataGridColumnHeader
title="Last Active"
visibility={true}
column={column}
/>
),
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{row.original.lastActive}
</span>
),
size: 130,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Last Active",
skeleton: <Skeleton className="h-4 w-28" />,
},
},
{
accessorKey: "seatLabel",
id: "seat",
header: ({ column }) => (
<DataGridColumnHeader title="Seat" visibility={true} column={column} />
),
cell: ({ row }) => (
<div className="min-w-0">
<div className="text-foreground truncate text-sm font-medium">
{row.original.seatLabel}
</div>
<div className="text-muted-foreground truncate text-xs">
{row.original.provisioning}
</div>
</div>
),
size: 140,
enableSorting: true,
enableHiding: true,
enableResizing: true,
meta: {
headerTitle: "Seat",
skeleton: (
<div className="flex min-w-0 flex-col gap-0.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</div>
),
},
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<ActionsCell row={row} onEditRole={onEditRole} onView={onView} />
),
size: 60,
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: {
skeleton: <Skeleton className="mx-auto size-7 rounded-md" />,
headerClassName:
"[--data-grid-header-cell-pe:var(--frame-panel-header-px)]",
cellClassName: "[--data-grid-body-cell-pe:var(--frame-panel-px)]",
},
},
]
}
@@ -0,0 +1,306 @@
export type MemberStatus = "Active" | "Invited" | "Suspended" | "Deactivated"
export type MemberRole =
| "Owner"
| "Admin"
| "Member"
| "Billing"
| "Guest"
| "Support Agent"
export type AuthMethod = "SSO" | "Password"
export type SsoProvider = "Okta" | "Microsoft Entra ID" | "Google Workspace"
export type TwoFactor = "Authenticator" | "Passkey" | "Security key" | "SMS"
export type Provisioning = "SCIM" | "JIT" | "Manual"
export type Scope = "None" | "Read" | "Write" | "Admin"
/** Closed vocabulary for team tags (filters + badge colors). */
export const TEAM_LABELS = [
"Engineering",
"Product",
"Design",
"Sales",
"Marketing",
"Customer Success",
"Finance",
"IT/Security",
] as const
export type TeamLabel = (typeof TEAM_LABELS)[number]
export interface IMember {
id: string
name: string
avatar: string
email: string
role: MemberRole
title: string
scope: Scope
teams: TeamLabel[]
status: MemberStatus
auth: AuthMethod
ssoProvider: SsoProvider | null
twoFactor: TwoFactor | null
provisioning: Provisioning
lastActive: string
lastActiveIso: string
seatLabel: string
}
// ── Status + role order (filter + bulk options) ──
export const STATUS_ORDER: MemberStatus[] = [
"Active",
"Invited",
"Suspended",
"Deactivated",
]
export const ROLE_ORDER: MemberRole[] = [
"Owner",
"Admin",
"Member",
"Billing",
"Guest",
"Support Agent",
]
/** Roles offered in the bulk change-role control. */
export const BULK_ROLE_OPTIONS: { value: MemberRole; label: string }[] = [
{ value: "Member", label: "Member" },
{ value: "Admin", label: "Admin" },
{ value: "Billing", label: "Billing" },
{ value: "Guest", label: "Guest" },
{ value: "Support Agent", label: "Support Agent" },
]
// ── Data (12 members) ──
export const MEMBERS: IMember[] = [
{
id: "usr_a1b2c3d4",
name: "Mira Stone",
avatar:
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
email: "[email protected]",
role: "Owner",
title: "Head of Product",
scope: "Admin",
teams: ["Product", "IT/Security"],
status: "Active",
auth: "SSO",
ssoProvider: "Okta",
twoFactor: "Passkey",
provisioning: "SCIM",
lastActive: "2 min ago",
lastActiveIso: "2026-06-17T14:12:00Z",
seatLabel: "Seat 1 of 80",
},
{
id: "usr_b2c3d4e5",
name: "Leo Grant",
avatar:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
email: "[email protected]",
role: "Admin",
title: "Eng Manager",
scope: "Admin",
teams: ["Engineering", "IT/Security"],
status: "Active",
auth: "SSO",
ssoProvider: "Microsoft Entra ID",
twoFactor: "Authenticator",
provisioning: "SCIM",
lastActive: "18 min ago",
lastActiveIso: "2026-06-17T13:56:00Z",
seatLabel: "Seat 4 of 80",
},
{
id: "usr_c3d4e5f6",
name: "Sarah Chen",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
email: "[email protected]",
role: "Admin",
title: "Eng Manager",
scope: "Admin",
teams: ["Engineering"],
status: "Active",
auth: "SSO",
ssoProvider: "Okta",
twoFactor: "Security key",
provisioning: "SCIM",
lastActive: "1 hour ago",
lastActiveIso: "2026-06-17T13:09:00Z",
seatLabel: "Seat 7 of 80",
},
{
id: "usr_d4e5f6a7",
name: "Nora Vale",
avatar:
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
email: "[email protected]",
role: "Member",
title: "Product Designer",
scope: "Write",
teams: ["Design", "Product"],
status: "Active",
auth: "SSO",
ssoProvider: "Google Workspace",
twoFactor: "Authenticator",
provisioning: "JIT",
lastActive: "3 hours ago",
lastActiveIso: "2026-06-17T11:05:00Z",
seatLabel: "Seat 12 of 80",
},
{
id: "usr_e5f6a7b8",
name: "Sana Qureshi",
avatar:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
email: "[email protected]",
role: "Member",
title: "Backend Engineer",
scope: "Write",
teams: ["Engineering"],
status: "Active",
auth: "SSO",
ssoProvider: "Okta",
twoFactor: "Authenticator",
provisioning: "SCIM",
lastActive: "5 hours ago",
lastActiveIso: "2026-06-17T09:21:00Z",
seatLabel: "Seat 18 of 80",
},
{
id: "usr_f6a7b8c9",
name: "David Kim",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
email: "[email protected]",
role: "Member",
title: "DevOps",
scope: "Write",
teams: ["Engineering", "IT/Security"],
status: "Active",
auth: "SSO",
ssoProvider: "Microsoft Entra ID",
twoFactor: "Security key",
provisioning: "SCIM",
lastActive: "Yesterday",
lastActiveIso: "2026-06-16T17:40:00Z",
seatLabel: "Seat 23 of 80",
},
{
id: "usr_a7b8c9d0",
name: "Michael Rodriguez",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
email: "[email protected]",
role: "Member",
title: "Account Executive",
scope: "Read",
teams: ["Sales"],
status: "Active",
auth: "Password",
ssoProvider: null,
twoFactor: "SMS",
provisioning: "Manual",
lastActive: "Yesterday",
lastActiveIso: "2026-06-16T15:18:00Z",
seatLabel: "Seat 31 of 80",
},
{
id: "usr_b8c9d0e1",
name: "Priya Patel",
avatar:
"https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=96&h=96&dpr=2&q=80",
email: "[email protected]",
role: "Billing",
title: "Finance",
scope: "Read",
teams: ["Finance"],
status: "Active",
auth: "SSO",
ssoProvider: "Google Workspace",
twoFactor: "Authenticator",
provisioning: "JIT",
lastActive: "2 days ago",
lastActiveIso: "2026-06-15T10:02:00Z",
seatLabel: "Seat 38 of 80",
},
{
id: "usr_c9d0e1f2",
name: "Emma Wilson",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
email: "[email protected]",
role: "Member",
title: "Marketing",
scope: "Read",
teams: ["Marketing"],
status: "Invited",
auth: "Password",
ssoProvider: null,
twoFactor: null,
provisioning: "Manual",
lastActive: "Invite sent",
lastActiveIso: "2026-06-14T09:30:00Z",
seatLabel: "Pending seat",
},
{
id: "usr_d0e1f2a3",
name: "Omar Haddad",
avatar:
"https://images.unsplash.com/photo-1507591064344-4c6ce005b128?w=96&h=96&dpr=2&q=80",
email: "[email protected]",
role: "Member",
title: "Data Analyst",
scope: "Read",
teams: ["Product"],
status: "Invited",
auth: "Password",
ssoProvider: null,
twoFactor: null,
provisioning: "Manual",
lastActive: "Expires in 3 days",
lastActiveIso: "2026-06-13T16:45:00Z",
seatLabel: "Pending seat",
},
{
id: "usr_e1f2a3b4",
name: "Kenji Tan",
avatar:
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
email: "[email protected]",
role: "Guest",
title: "Contractor",
scope: "Read",
teams: ["Design"],
status: "Suspended",
auth: "SSO",
ssoProvider: "Google Workspace",
twoFactor: "SMS",
provisioning: "JIT",
lastActive: "14 days ago",
lastActiveIso: "2026-06-03T12:00:00Z",
seatLabel: "Seat 52 of 80",
},
{
id: "usr_f2a3b4c5",
name: "Alex Johnson",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
email: "[email protected]",
role: "Member",
title: "QA Engineer",
scope: "None",
teams: ["Engineering"],
status: "Deactivated",
auth: "Password",
ssoProvider: null,
twoFactor: null,
provisioning: "Manual",
lastActive: "97 days ago",
lastActiveIso: "2026-03-12T08:15:00Z",
seatLabel: "Seat released",
},
]
@@ -0,0 +1,181 @@
"use client"
import { type ReactNode } from "react"
import { toast } from "sonner"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@authportal/ui/components/avatar"
import { Button } from "@authportal/ui/components/button"
import { ScrollArea } from "@authportal/ui/components/scroll-area"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@authportal/ui/components/sheet"
import { RoleBadge, StatusBadge } from "./columns"
import { type IMember } from "./data"
import { XIcon, PencilIcon } from "lucide-react"
const mutedIconButtonClassName = "text-muted-foreground hover:text-foreground"
function DetailRow({
label,
children,
}: {
label: string
children: ReactNode
}) {
return (
<div className="flex min-h-9 items-center justify-between gap-3 px-4 py-1">
<span className="text-muted-foreground shrink-0 text-sm">{label}</span>
<span className="flex min-w-0 items-center justify-end gap-1.5 text-sm font-medium">
{children}
</span>
</div>
)
}
export function MemberDetailSheet({
member,
open,
onOpenChange,
}: {
member: IMember | null
open: boolean
onOpenChange: (open: boolean) => void
}) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto h-[calc(100svh-2rem)] w-[min(30rem,calc(100vw-2rem))] max-w-none overflow-hidden rounded-xl p-0 outline-none"
>
{/* Header */}
<SheetHeader className="shrink-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">
Member Profile
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Close sheet"
className={mutedIconButtonClassName}
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="sr-only">
Review role, teams, and authentication for this member.
</SheetDescription>
</SheetHeader>
{/* Content */}
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
{member ? (
<div className="flex min-h-full flex-col pb-6">
<div className="flex items-center gap-3 px-4 py-5">
<Avatar className="size-12 shrink-0">
<AvatarImage src={member.avatar} alt="" />
<AvatarFallback>
{member.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="text-foreground truncate text-sm font-semibold">
{member.name}
</div>
<div className="text-muted-foreground truncate text-xs">
{member.title}
</div>
<div className="text-muted-foreground truncate text-xs">
{member.email}
</div>
</div>
</div>
<div className="flex flex-col gap-0 border-t pt-1">
<DetailRow label="Role">
<RoleBadge role={member.role} />
</DetailRow>
<DetailRow label="Status">
<StatusBadge status={member.status} />
</DetailRow>
<DetailRow label="Permission scope">{member.scope}</DetailRow>
<DetailRow label="Teams">
<span className="truncate">{member.teams.join(", ")}</span>
</DetailRow>
<DetailRow label="Sign-in">
{member.auth === "SSO"
? (member.ssoProvider ?? "SSO")
: "Password"}
</DetailRow>
<DetailRow label="Two-factor">
{member.twoFactor ?? "Not enrolled"}
</DetailRow>
<DetailRow label="Provisioning">
{member.provisioning}
</DetailRow>
<DetailRow label="Seat">{member.seatLabel}</DetailRow>
<DetailRow label="Last active">{member.lastActive}</DetailRow>
<DetailRow label="Member ID">
<span className="truncate font-mono text-xs">
{member.id}
</span>
</DetailRow>
</div>
</div>
) : null}
</ScrollArea>
</div>
{/* Footer */}
<SheetFooter className="bg-background shrink-0 border-t">
<div className="flex w-full gap-2">
<Button
type="button"
className="min-w-0 flex-1"
onClick={() => {
if (!member) return
toast.info("Role editor", {
description: `Update ${member.name} from ${member.role}. Demo only.`,
})
}}
>
<PencilIcon aria-hidden="true" />
Edit role
</Button>
<SheetClose
render={
<Button
type="button"
variant="outline"
className="min-w-0 flex-1"
>
Close
</Button>
}
/>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,550 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { DataGrid } from "@/components/reui/data-grid/data-grid"
import { DataGridColumnVisibility } from "@/components/reui/data-grid/data-grid-column-visibility"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
RowSelectionState,
SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table"
import { toast } from "sonner"
import { Button } from "@authportal/ui/components/button"
import { Separator } from "@authportal/ui/components/separator"
import { TooltipProvider } from "@authportal/ui/components/tooltip"
import { BulkActionBar } from "./bulk-action-bar"
import { createMemberColumns, RoleBadge, StatusBadge } from "./columns"
import {
MEMBERS,
ROLE_ORDER,
STATUS_ORDER,
TEAM_LABELS,
type IMember,
type MemberRole,
type MemberStatus,
type TeamLabel,
} from "./data"
import { MemberDetailSheet } from "./member-detail-sheet"
import { UserIcon, MailIcon, ShieldCheckIcon, CircleDotIcon, UsersIcon, UserPlusIcon, FilterIcon, FunnelXIcon, Settings2Icon } from "lucide-react"
// ── Helpers ──
function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (
values.every((value) => typeof value === "string" && value.trim() === "")
)
return false
if (values.every((value) => value === null || value === undefined))
return false
if (values.every((value) => Array.isArray(value) && value.length === 0))
return false
return true
})
}
function serializeActiveFiltersKey(active: Filter[]) {
return JSON.stringify(
active.map((f) => ({
field: f.field,
operator: f.operator,
values: f.values,
}))
)
}
function filterFieldValue(item: IMember, field: string): unknown {
if (field === "teams") return item.teams.join(" ")
return item[field as keyof IMember]
}
function applyFiltersToData(data: IMember[], filters: Filter[]): IMember[] {
const active = getActiveFilters(filters)
let result = [...data]
active.forEach((filter) => {
const { field, operator, values } = filter
result = result.filter((item) => {
if (field === "teams") {
const selected = values.map(String)
switch (operator) {
case "is":
return (
selected.length > 0 &&
item.teams.includes(selected[0] as TeamLabel)
)
case "is_not":
return !selected.some((v) => item.teams.includes(v as TeamLabel))
case "is_any_of":
return selected.some((v) => item.teams.includes(v as TeamLabel))
case "is_not_any_of":
return !selected.some((v) => item.teams.includes(v as TeamLabel))
case "contains": {
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
item.teams.some((t) =>
t.toLowerCase().includes(token.toLowerCase())
)
)
}
case "not_contains":
return !values.some((v) =>
item.teams.some((t) =>
t.toLowerCase().includes(String(v).toLowerCase())
)
)
default:
return true
}
}
const raw = filterFieldValue(item, field)
const fieldValue = raw != null ? raw : ""
switch (operator) {
case "is":
return values.includes(fieldValue)
case "is_not":
return !values.includes(fieldValue)
case "is_any_of":
return values.some((v) => fieldValue === v)
case "is_not_any_of":
return !values.some((v) => fieldValue === v)
case "contains": {
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase())
)
}
case "not_contains":
return !values.some((v) =>
String(fieldValue).toLowerCase().includes(String(v).toLowerCase())
)
case "starts_with":
return values.some((v) =>
String(fieldValue).toLowerCase().startsWith(String(v).toLowerCase())
)
case "ends_with":
return values.some((v) =>
String(fieldValue).toLowerCase().endsWith(String(v).toLowerCase())
)
case "empty":
return fieldValue === "" || fieldValue == null
case "not_empty":
return fieldValue !== "" && fieldValue != null
default:
return true
}
})
})
return result
}
const STATUS_OPTIONS: { value: MemberStatus; label: string }[] =
STATUS_ORDER.map((status) => ({ value: status, label: status }))
const ROLE_OPTIONS: { value: MemberRole; label: string }[] = ROLE_ORDER.map(
(role) => ({ value: role, label: role })
)
function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return "Select..."
if (values.length > 1) return `${values.length} selected`
return null
}
function createDefaultMemberFilters(): Filter[] {
return [createFilter("name", "contains", [""])]
}
function DotSeparator() {
return (
<span
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
)
}
// ── Main ──
export function MembersGrid() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
seat: false,
})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [filters, setFilters] = useState<Filter[]>(createDefaultMemberFilters)
const [bulkRole, setBulkRole] = useState<MemberRole>("Member")
const [activeMember, setActiveMember] = useState<IMember | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [filteredData, setFilteredData] = useState<IMember[]>(MEMBERS)
const isInitialLoad = useRef(true)
const lastAppliedActiveKey = useRef<string>(
serializeActiveFiltersKey(getActiveFilters(createDefaultMemberFilters()))
)
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: "name",
label: "Member",
icon: (
<UserIcon className="size-3.5" aria-hidden />
),
type: "text",
className: "w-44",
placeholder: "Search...",
},
{
key: "email",
label: "Email",
icon: (
<MailIcon className="size-3.5" aria-hidden />
),
type: "text",
className: "w-48",
placeholder: "Search...",
},
{
key: "role",
label: "Role",
icon: (
<ShieldCheckIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[160px]",
options: ROLE_OPTIONS,
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return <RoleBadge role={values[0] as MemberRole} />
},
},
{
key: "status",
label: "Status",
icon: (
<CircleDotIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: false,
className: "w-[150px]",
options: STATUS_OPTIONS,
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return <StatusBadge status={values[0] as MemberStatus} />
},
},
{
key: "teams",
label: "Team",
icon: (
<UsersIcon className="size-3.5" aria-hidden />
),
type: "select",
searchable: true,
className: "w-[190px]",
options: TEAM_LABELS.map((team) => ({
value: team,
label: team,
})),
customValueRenderer: (values) => {
const state = renderSelectedCount(values)
if (state) return state
return String(values[0])
},
},
],
[]
)
const applyFilters = useCallback((newFilters: Filter[]) => {
return applyFiltersToData(MEMBERS, newFilters)
}, [])
const simulateAsyncFiltering = useCallback(
async (newFilters: Filter[]) => {
setIsLoading(true)
await new Promise((resolve) => setTimeout(resolve, 400))
setFilteredData(applyFilters(newFilters))
setIsLoading(false)
},
[applyFilters]
)
const handleFiltersChange = useCallback(
(newFilters: Filter[]) => {
setFilters(newFilters)
const newActive = getActiveFilters(newFilters)
const nextKey = serializeActiveFiltersKey(newActive)
if (nextKey === lastAppliedActiveKey.current) return
lastAppliedActiveKey.current = nextKey
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
setRowSelection({})
simulateAsyncFiltering(newFilters)
},
[simulateAsyncFiltering]
)
useEffect(() => {
if (isInitialLoad.current) {
setFilteredData(applyFilters(filters))
isInitialLoad.current = false
}
}, [filters, applyFilters])
const handleView = useCallback((member: IMember) => {
setActiveMember(member)
setSheetOpen(true)
}, [])
const handleEditRole = useCallback((member: IMember) => {
toast.info("Role editor", {
description: `Update ${member.name} from ${member.role}. Demo only.`,
})
}, [])
const columns = useMemo(
() =>
createMemberColumns({ onEditRole: handleEditRole, onView: handleView }),
[handleEditRole, handleView]
)
const [columnOrder, setColumnOrder] = useState<string[]>(
columns.map((c) => c.id as string)
)
const table = useReactTable({
columns,
data: filteredData,
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
getRowId: (row) => row.id,
state: {
pagination,
sorting,
columnOrder,
columnVisibility,
rowSelection,
},
enableRowSelection: true,
columnResizeMode: "onChange",
onColumnOrderChange: setColumnOrder,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
const selectedCount = table.getSelectedRowModel().rows.length
const handleClearSelection = useCallback(() => setRowSelection({}), [])
const handleChangeRole = useCallback(() => {
if (selectedCount === 0) return
setRowSelection({})
toast.success("Role updated", {
description: `${selectedCount} member${selectedCount === 1 ? "" : "s"} moved to ${bulkRole}.`,
})
}, [bulkRole, selectedCount])
const handleBulkResend = useCallback(() => {
if (selectedCount === 0) return
setRowSelection({})
toast.info("Invites resent", {
description: `${selectedCount} link${selectedCount === 1 ? "" : "s"} sent. Each expires in 7 days.`,
})
}, [selectedCount])
const handleBulkDeactivate = useCallback(() => {
if (selectedCount === 0) return
setRowSelection({})
toast.message("Members deactivated", {
description: `${selectedCount} member${selectedCount === 1 ? "" : "s"} can no longer sign in.`,
})
}, [selectedCount])
const showClearButton = filters.length > 0
return (
<TooltipProvider delay={200}>
{/* Table */}
<DataGrid
table={table}
isLoading={isLoading}
loadingMode="skeleton"
recordCount={filteredData.length}
emptyMessage={
!isLoading && filteredData.length === 0
? "No members match your filters. Clear filters or adjust operators."
: undefined
}
tableLayout={{
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
headerSticky: true,
dense: true,
}}
>
<Frame spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle id="page-heading" className="text-balance">
Members
</FrameTitle>
<FrameDescription className="flex items-center gap-1.5 text-xs text-pretty">
<span>68 of 80 seats</span>
<DotSeparator />
<span>5 pending invites</span>
</FrameDescription>
</div>
<Button
type="button"
size="default"
onClick={() =>
toast.info("Invite people", {
description: "Send to acmecloud.com addresses. Demo only.",
})
}
>
<UserPlusIcon aria-hidden="true" />
Invite people
</Button>
</FrameHeader>
<FramePanel className="p-0 shadow-none">
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button
type="button"
size="default"
variant="outline"
aria-label="Filters"
>
<FilterIcon aria-hidden />
Filters
</Button>
}
/>
<div className="flex flex-wrap items-center gap-2">
{showClearButton && (
<Button
type="button"
size="default"
variant="outline"
className="shrink-0"
onClick={() => {
const next = createDefaultMemberFilters()
lastAppliedActiveKey.current = serializeActiveFiltersKey(
getActiveFilters(next)
)
setFilters(next)
setRowSelection({})
simulateAsyncFiltering(next)
}}
disabled={isLoading}
>
<FunnelXIcon aria-hidden />
Clear
</Button>
)}
<DataGridColumnVisibility
table={table}
trigger={
<Button
type="button"
size="default"
variant="outline"
aria-label="View settings"
>
<Settings2Icon aria-hidden="true" />
View settings
</Button>
}
/>
</div>
</div>
<Separator />
{selectedCount > 0 ? (
<>
<BulkActionBar
selectedCount={selectedCount}
roleValue={bulkRole}
onRoleChange={setBulkRole}
onChangeRole={handleChangeRole}
onResendInvite={handleBulkResend}
onDeactivate={handleBulkDeactivate}
onClear={handleClearSelection}
/>
<Separator />
</>
) : null}
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination sizes={[10, 20, 30]} />
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
<MemberDetailSheet
member={activeMember}
open={sheetOpen}
onOpenChange={setSheetOpen}
/>
</TooltipProvider>
)
}
export { MembersGrid as DataGridView }
@@ -0,0 +1,27 @@
"use client"
import { useEffect, useState } from "react"
import { MembersGrid } from "./components/members-grid"
export function Page() {
const [isReady, setIsReady] = useState(false)
useEffect(() => setIsReady(true), [])
return (
<main
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Members directory data grid
</h1>
{isReady ? (
<MembersGrid />
) : (
<div className="bg-background min-h-svh w-full" aria-hidden="true" />
)}
</main>
)
}
@@ -0,0 +1,28 @@
import type { CSSProperties, ReactNode } from 'react'
import { AppSidebar } from '@/components/app-sidebar'
import { SiteHeader } from '@/components/layout/site-header'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar'
/** Shared ops chrome — etalon app-shell-12. */
export function AppShell({ children }: { children: ReactNode }) {
return (
<TooltipProvider delay={0}>
<SidebarProvider
style={
{
'--sidebar-width': '240px',
} as CSSProperties
}
>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</main>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
)
}
@@ -0,0 +1,63 @@
import { LayoutGridIcon, KeyRoundIcon, CloudIcon, ServerIcon, NetworkIcon } from 'lucide-react'
import { APPS } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
const APP_ICONS = {
cfdm: CloudIcon,
vps: ServerIcon,
bgp: NetworkIcon,
} as const
export function AppsMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon className="size-4.5" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" sideOffset={8} className="w-72">
<DropdownMenuGroup>
<DropdownMenuLabel>Приложения</DropdownMenuLabel>
<div className="grid grid-cols-2 gap-1 p-1">
<DropdownMenuItem
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<KeyRoundIcon aria-hidden />
</span>
<span className="text-xs font-medium">Auth Portal</span>
</DropdownMenuItem>
{APPS.map((app) => {
const Icon = APP_ICONS[app.id]
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} target="_blank" rel="noreferrer" />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden />
</span>
<span className="text-xs font-medium">{app.title}</span>
</DropdownMenuItem>
)
})}
</div>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,124 @@
import { Fragment, useMemo } from 'react'
import { Link, useRouterState } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { LogOutIcon } from 'lucide-react'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@authportal/ui/components/breadcrumb'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { Separator } from '@authportal/ui/components/separator'
import { SidebarTrigger } from '@authportal/ui/components/sidebar'
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
import { AppsMenu } from '@/components/layout/apps-menu'
import { ModeToggle } from '@/components/mode-toggle'
import { clearToken } from '@/lib/auth'
import { logout, meQueryOptions } from '@/queries/auth'
function breadcrumbs(pathname: string) {
if (pathname.startsWith('/admin/users/')) {
return [
{ label: 'Пользователи', href: '/admin' },
{ label: 'Права доступа', href: pathname },
]
}
if (pathname.startsWith('/admin')) {
return [{ label: 'Пользователи', href: '/admin' }]
}
if (pathname.startsWith('/apps')) {
return [{ label: 'Приложения', href: '/apps' }]
}
return [{ label: 'Auth Portal', href: '/apps' }]
}
export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const crumbs = useMemo(() => breadcrumbs(pathname), [pathname])
const { data: me } = useQuery(meQueryOptions)
const queryClient = useQueryClient()
async function handleLogout() {
try {
await logout()
} catch {
/* ignore */
}
clearToken()
queryClient.clear()
window.location.href = '/'
}
const initials = (me?.name ?? me?.email ?? '?')
.split(/\s+/)
.map((p) => p[0])
.join('')
.slice(0, 2)
.toUpperCase()
return (
<header className="bg-background sticky top-0 z-20 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-1 h-4" />
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((crumb, i) => {
const last = i === crumbs.length - 1
return (
<Fragment key={crumb.href}>
{i > 0 ? <BreadcrumbSeparator /> : null}
<BreadcrumbItem>
{last ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
) : (
<BreadcrumbLink render={<Link to={crumb.href} />}>
{crumb.label}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</Fragment>
)
})}
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-1">
<AppsMenu />
<ModeToggle />
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon" className="rounded-full" />}
>
<Avatar className="size-7">
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{me?.name}</span>
<span className="text-muted-foreground text-xs">{me?.email}</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOutIcon />
Выйти
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" />}>
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Сменить тему</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>
Светлая
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Тёмная
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
Системная
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
+16
View File
@@ -0,0 +1,16 @@
import type { ReactNode } from 'react'
import { cn } from '@authportal/ui/lib/utils'
export function PageShell({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>
{children}
</div>
)
}
@@ -0,0 +1,146 @@
import { useState, type FormEvent } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { EyeIcon, EyeOffIcon } from 'lucide-react'
import { buildSsoRedirectUrl, isReturnToAllowed } from '@authportal/shared'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@authportal/ui/components/input-group'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { setToken } from '@/lib/auth'
import { ApiError } from '@/lib/api-client'
import { login, meQueryKey } from '@/queries/auth'
import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo'
/** Dev default matches auth-portal .env.example; prod should pass via Vite if needed. */
const RETURN_TO_ALLOWLIST =
import.meta.env.VITE_RETURN_TO_ALLOWLIST ??
'.shnt.top,localhost,http://localhost:5173'
export function PortalLoginForm() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const search = useSearch({ from: '/' }) as { return_to?: string }
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
setPending(true)
const form = new FormData(event.currentTarget)
const email = String(form.get('email') ?? '')
const password = String(form.get('password') ?? '')
const returnTo = search.return_to
try {
const res = await login(email, password, returnTo)
setToken(res.access_token)
queryClient.setQueryData(meQueryKey, res.user)
if (
returnTo &&
isReturnToAllowed(returnTo, RETURN_TO_ALLOWLIST)
) {
window.location.href = buildSsoRedirectUrl(
returnTo,
res.access_token,
res.expires_at,
)
return
}
if (res.user.is_admin) {
await navigate({ to: '/admin' })
} else {
await navigate({ to: '/apps' })
}
} catch (err) {
setError(
err instanceof ApiError ? err.message : 'Не удалось войти',
)
} finally {
setPending(false)
}
}
return (
<section className="flex min-h-svh min-w-0 flex-col justify-center py-8">
<div className="mx-auto flex w-full max-w-90 flex-col gap-6 px-4">
<div className="flex flex-col items-center gap-3 text-center">
<AuthLogo />
<div className="flex flex-col gap-1">
<h1 className="text-xl font-semibold tracking-tight">
Auth Portal
</h1>
<p className="text-muted-foreground text-sm">
Единый вход в приложения shnt.top
</p>
</div>
</div>
{error ? (
<Alert variant="destructive">
<AlertTitle>Ошибка входа</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<FieldGroup className="gap-3.5">
<Field className="gap-2">
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
name="email"
type="email"
autoComplete="username"
placeholder="[email protected]"
className="bg-background"
required
/>
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="password">Пароль</FieldLabel>
<InputGroup className="bg-background w-full">
<InputGroupInput
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder="Пароль"
required
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
size="icon-xs"
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
<Button type="submit" className="w-full" disabled={pending}>
{pending ? 'Вход…' : 'Войти'}
</Button>
</form>
</div>
</section>
)
}
@@ -0,0 +1,6 @@
/**
* Minimal ResourcePage stub for list screens.
* Full CFDM kit is heavier; auth-portal users list uses Frame + DataGrid inline.
* Preview: https://reui.io/preview/base/data-grid-filtering-2
*/
export { PageShell } from '@/components/page-shell'
@@ -0,0 +1,485 @@
/**
* User access editor — adapted from ReUI PRO solution-users-1 member detail sheet.
* Preview: https://reui.io/preview/base/solution-users-1
* Docs: https://reui.io/blocks
*/
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { InfoIcon, XIcon } from 'lucide-react'
import {
APP_IDS,
APPS,
PERMISSION_CATALOG,
permissionKey,
type AdminUser,
type AppId,
type CatalogSection,
type PermissionAction,
} from '@authportal/shared'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import {
Avatar,
AvatarFallback,
} from '@authportal/ui/components/avatar'
import { Button } from '@authportal/ui/components/button'
import { ButtonGroup } from '@authportal/ui/components/button-group'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from '@authportal/ui/components/item'
import { ScrollArea } from '@authportal/ui/components/scroll-area'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { Switch } from '@authportal/ui/components/switch'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@authportal/ui/components/tabs'
import { cn } from '@authportal/ui/lib/utils'
import { api, ApiError } from '@/lib/api-client'
import { userQueryOptions, usersQueryKey } from '@/queries/auth'
type AccessLevel = 'none' | 'read' | 'write' | 'admin'
const LEVEL_LABELS: Record<AccessLevel, string> = {
none: 'Нет',
read: 'Чтение',
write: 'Редактирование',
admin: 'Админ',
}
const mutedIconButtonClassName = 'text-muted-foreground hover:text-foreground'
function levelsForSection(section: CatalogSection): AccessLevel[] {
const levels: AccessLevel[] = ['none']
if (section.actions.includes('read')) levels.push('read')
if (section.actions.includes('write')) levels.push('write')
if (section.actions.includes('admin')) levels.push('admin')
return levels
}
function getSectionLevel(
appId: AppId,
section: CatalogSection,
permissionSet: Set<string>,
): AccessLevel {
const has = (action: PermissionAction) =>
permissionSet.has(permissionKey(appId, section.id, action))
if (section.actions.includes('admin') && has('admin')) return 'admin'
if (section.actions.includes('write') && has('write')) return 'write'
if (section.actions.includes('read') && has('read')) return 'read'
return 'none'
}
function keysForLevel(
appId: AppId,
section: CatalogSection,
level: AccessLevel,
): string[] {
if (level === 'none') return []
if (level === 'read') {
return section.actions.includes('read')
? [permissionKey(appId, section.id, 'read')]
: []
}
if (level === 'write') {
const keys: string[] = []
if (section.actions.includes('read')) {
keys.push(permissionKey(appId, section.id, 'read'))
}
if (section.actions.includes('write')) {
keys.push(permissionKey(appId, section.id, 'write'))
}
return keys
}
return section.actions.map((action) =>
permissionKey(appId, section.id, action),
)
}
export function UserAccessSheet({
userId,
open,
onOpenChange,
}: {
userId: string | null
open: boolean
onOpenChange: (open: boolean) => void
}) {
const queryClient = useQueryClient()
const enabled = open && Boolean(userId)
const { data: user, isLoading, error } = useQuery({
...userQueryOptions(userId ?? ''),
enabled,
})
const [apps, setApps] = useState<AppId[]>([])
const [permissions, setPermissions] = useState<string[]>([])
const [activeApp, setActiveApp] = useState<AppId>('cfdm')
useEffect(() => {
if (!user) return
setApps(user.apps)
setPermissions(user.permissions)
const firstEnabled =
APP_IDS.find((id) => user.apps.includes(id)) ?? APP_IDS[0]
setActiveApp(firstEnabled)
}, [user])
const baseline = useMemo(() => {
if (!user) return null
return {
apps: [...user.apps].sort().join(','),
permissions: [...user.permissions].sort().join(','),
}
}, [user])
const dirty = useMemo(() => {
if (!baseline) return false
const current = {
apps: [...apps].sort().join(','),
permissions: [...permissions].sort().join(','),
}
return (
current.apps !== baseline.apps ||
current.permissions !== baseline.permissions
)
}, [apps, permissions, baseline])
const saveMutation = useMutation({
mutationFn: () =>
api.put<AdminUser>(`/api/v1/admin/users/${userId}/access`, {
apps,
permissions,
}),
onSuccess: async (updated) => {
await queryClient.invalidateQueries({ queryKey: usersQueryKey })
if (userId) {
queryClient.setQueryData(userQueryOptions(userId).queryKey, updated)
}
onOpenChange(false)
},
})
const permissionSet = useMemo(() => new Set(permissions), [permissions])
function toggleApp(appId: AppId, enabledApp: boolean) {
setApps((prev) => {
if (enabledApp) return prev.includes(appId) ? prev : [...prev, appId]
return prev.filter((id) => id !== appId)
})
if (!enabledApp) {
setPermissions((prev) => prev.filter((p) => !p.startsWith(`${appId}:`)))
} else {
setActiveApp(appId)
}
}
function setSectionLevel(
appId: AppId,
section: CatalogSection,
level: AccessLevel,
) {
const sectionPrefix = `${appId}:${section.id}:`
const nextKeys = keysForLevel(appId, section, level)
setPermissions((prev) => {
const kept = prev.filter((p) => !p.startsWith(sectionPrefix))
return [...kept, ...nextKeys]
})
}
function resetChanges() {
if (!user) return
setApps(user.apps)
setPermissions(user.permissions)
}
const initials = (user?.name ?? '?')
.split(/\s+/)
.map((p) => p[0])
.join('')
.slice(0, 2)
.toUpperCase()
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="inset-y-4 right-4 left-auto flex h-[calc(100svh-2rem)] w-[min(36rem,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden rounded-xl p-0 outline-none sm:max-w-none"
>
<SheetHeader className="shrink-0 gap-0 p-0">
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
<SheetTitle className="min-w-0 truncate text-base font-semibold">
Права доступа
</SheetTitle>
<SheetClose
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Закрыть"
className={mutedIconButtonClassName}
>
<XIcon aria-hidden="true" />
</Button>
}
/>
</div>
<SheetDescription className="sr-only">
Настройка доступа к приложениям и разделам
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
{isLoading ? (
<div className="flex flex-col gap-3 p-4">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : error || !user ? (
<div className="p-4">
<Alert variant="destructive">
<InfoIcon />
<AlertTitle>Ошибка</AlertTitle>
<AlertDescription>
{error instanceof ApiError
? error.message
: 'Пользователь не найден'}
</AlertDescription>
</Alert>
</div>
) : (
<div className="flex flex-col pb-4">
<div className="flex items-center gap-3 px-4 py-5">
<Avatar className="size-12 shrink-0">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-foreground truncate text-sm font-semibold">
{user.name}
</div>
{user.is_admin ? (
<Badge variant="warning-light" size="sm">
Админ
</Badge>
) : null}
{user.disabled ? (
<Badge variant="destructive-light" size="sm">
Отключён
</Badge>
) : (
<Badge variant="success-light" size="sm">
Активен
</Badge>
)}
</div>
<div className="text-muted-foreground truncate text-xs">
{user.email}
</div>
</div>
</div>
<div className="flex flex-col gap-4 border-t px-4 pt-4">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Доступ к приложениям</p>
<div className="flex flex-col gap-2">
{APP_IDS.map((appId) => {
const meta = APPS.find((a) => a.id === appId)
const checked = apps.includes(appId)
return (
<Item
key={appId}
variant="outline"
className={cn(
'items-start gap-3',
checked && 'border-primary/40',
)}
>
<ItemContent className="gap-1">
<ItemTitle className="text-sm">
{meta?.title ?? appId}
</ItemTitle>
<ItemDescription className="text-xs">
{meta?.description ?? appId}
</ItemDescription>
</ItemContent>
<ItemActions className="ml-auto self-center">
<Switch
checked={checked}
onCheckedChange={(v) => toggleApp(appId, v)}
aria-label={meta?.title ?? appId}
/>
</ItemActions>
</Item>
)
})}
</div>
</div>
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Права по разделам</p>
<Tabs
value={activeApp}
onValueChange={(v) => setActiveApp(v as AppId)}
className="w-full gap-3"
>
<TabsList
variant="line"
className="h-auto w-full flex-wrap justify-start gap-4 border-b"
>
{PERMISSION_CATALOG.map((app) => {
const enabledApp = apps.includes(app.appId)
return (
<TabsTrigger
key={app.appId}
value={app.appId}
className="text-muted-foreground hover:text-foreground data-active:bg-transparent data-active:text-foreground dark:data-active:border-transparent dark:data-active:bg-transparent h-auto flex-none gap-1.5 rounded-none bg-transparent px-0 pb-2.5 text-sm shadow-none after:bottom-0 after:h-0.5 data-active:shadow-none"
>
<span className="max-w-28 truncate sm:max-w-none">
{app.title}
</span>
<span className="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-[0.625rem] tabular-nums">
{enabledApp ? 'вкл' : 'выкл'}
</span>
</TabsTrigger>
)
})}
</TabsList>
{PERMISSION_CATALOG.map((app) => (
<TabsContent
key={app.appId}
value={app.appId}
className="mt-0 w-full"
>
{!apps.includes(app.appId) ? (
<Alert variant="info" className="mb-3">
<InfoIcon />
<AlertTitle>Приложение выключено</AlertTitle>
<AlertDescription>
Включите «{app.title}», чтобы настроить разделы.
</AlertDescription>
</Alert>
) : null}
<div
className={cn(
'grid grid-cols-1 gap-2',
!apps.includes(app.appId) &&
'pointer-events-none opacity-50',
)}
>
{app.sections.map((section) => {
const level = getSectionLevel(
app.appId,
section,
permissionSet,
)
const levels = levelsForSection(section)
return (
<Item
key={section.id}
variant="outline"
className="items-start gap-2"
>
<ItemContent className="gap-2">
<ItemTitle className="text-sm">
{section.title}
</ItemTitle>
<ItemDescription className="text-xs">
{section.description}
</ItemDescription>
<ButtonGroup className="mt-0.5 flex-wrap">
{levels.map((lvl) => (
<Button
key={lvl}
size="sm"
variant={
level === lvl
? 'default'
: 'outline'
}
disabled={!apps.includes(app.appId)}
onClick={() =>
setSectionLevel(
app.appId,
section,
lvl,
)
}
>
{LEVEL_LABELS[lvl]}
</Button>
))}
</ButtonGroup>
</ItemContent>
</Item>
)
})}
</div>
</TabsContent>
))}
</Tabs>
</div>
</div>
</div>
)}
</ScrollArea>
</div>
<SheetFooter className="bg-background shrink-0 border-t">
{saveMutation.error ? (
<p className="text-destructive w-full text-sm">
{saveMutation.error instanceof ApiError
? saveMutation.error.message
: 'Ошибка сохранения'}
</p>
) : null}
<div className="flex w-full gap-2">
<Button
type="button"
variant="outline"
className="min-w-0 flex-1"
disabled={!dirty || saveMutation.isPending}
onClick={resetChanges}
>
Отменить
</Button>
<Button
type="button"
className="min-w-0 flex-1"
disabled={!dirty || saveMutation.isPending || !user}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? 'Сохранение…' : 'Сохранить'}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
const alertVariants = cva(
[
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
"rounded-lg",
"px-3",
"py-2.5",
"has-[>svg]:gap-x-2.5",
],
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
info: "border-info/30 bg-info/4 [&>svg]:text-info",
success: "border-success/30 bg-success/4 [&>svg]:text-success",
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
invert:
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn(
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+102
View File
@@ -0,0 +1,102 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
const badgeVariants = cva(
[
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
],
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
outline: "border-border bg-transparent dark:bg-input/32",
secondary: "bg-secondary text-secondary-foreground",
info: "bg-info text-white",
success: "bg-success text-white",
warning: "bg-warning text-white",
destructive: "bg-destructive text-white",
focus: "bg-focus text-focus-foreground",
invert: "bg-invert text-invert-foreground",
"primary-light":
"border-primary/10 bg-primary/10 text-primary dark:border-primary/25 dark:bg-primary/15 dark:text-primary",
"warning-light":
"border-warning/15 bg-warning/10 text-warning-foreground dark:border-warning/25 dark:bg-warning/15 dark:text-warning",
"success-light":
"border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success",
"info-light":
"border-info/15 bg-info/10 text-info-foreground dark:border-info/25 dark:bg-info/15 dark:text-info",
"destructive-light":
"border-destructive/15 bg-destructive/10 text-destructive-foreground dark:border-destructive/25 dark:bg-destructive/15 dark:text-destructive",
"invert-light":
"border-invert/15 bg-invert/10 text-foreground dark:border-invert/45 dark:bg-invert/35 dark:text-invert-foreground",
"focus-light":
"border-focus/15 bg-focus/10 text-focus-foreground dark:border-focus/25 dark:bg-focus/15 dark:text-focus",
"primary-outline":
"bg-background border-border text-primary dark:bg-input/30",
"warning-outline":
"bg-background border-border text-warning-foreground dark:bg-input/30",
"success-outline":
"bg-background border-border text-success-foreground dark:bg-input/30",
"info-outline":
"bg-background border-border text-info-foreground dark:bg-input/30",
"destructive-outline":
"bg-background border-border text-destructive-foreground dark:bg-input/30",
"invert-outline":
"bg-background border-border text-invert-foreground dark:bg-input/30",
"focus-outline":
"bg-background border-border text-focus-foreground dark:bg-input/30",
},
size: {
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
},
/** `default`: active style radius. `full`: pill radius. */
radius: {
default:
"rounded-sm",
full: "rounded-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
radius: "default",
},
}
)
interface BadgeProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof badgeVariants>["variant"]
size?: VariantProps<typeof badgeVariants>["size"]
radius?: VariantProps<typeof badgeVariants>["radius"]
}
function Badge({
className,
variant,
size,
radius,
render,
...props
}: BadgeProps) {
const defaultProps = {
"data-slot": "badge",
className: cn(badgeVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { Badge, badgeVariants, type BadgeProps }
@@ -0,0 +1,186 @@
"use client"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { Column } from "@tanstack/react-table"
import { cn } from "@authportal/ui/lib/utils"
import { Button } from "@authportal/ui/components/button"
import { Input } from "@authportal/ui/components/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@authportal/ui/components/popover"
import { Separator } from "@authportal/ui/components/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData, TValue> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
function DataGridColumnFilter<TData, TValue>({
column,
title,
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const filterValue = column?.getFilterValue()
const selectedValues = new Set(
Array.isArray(filterValue) ? (filterValue as string[]) : []
)
const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => {
if (!searchQuery) return options
return options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [options, searchQuery])
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm">
<CirclePlusIcon className="size-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="px-1 font-normal">
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
}
/>
<PopoverContent className="w-[200px] p-0" align="start">
<div className="p-2">
<Input
placeholder={title}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
No results found.
</div>
) : (
<div className="p-1">
{filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value)
const facetCount = facets?.get(option.value)
const toggleOption = () => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}
return (
<div
key={option.value}
role="button"
tabIndex={0}
aria-pressed={isSelected}
onClick={toggleOption}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
toggleOption()
}
}}
className={cn(
"rounded-md relative flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)}
>
<div
className={cn(
"border-primary rounded-sm flex h-4 w-4 items-center justify-center border",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground h-4 w-4" />
)}
<span>{option.label}</span>
{facetCount !== undefined && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facetCount}
</span>
)}
</div>
)
})}
</div>
)}
{selectedValues.size > 0 && (
<>
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
role="button"
tabIndex={0}
onClick={() => column?.setFilterValue(undefined)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
column?.setFilterValue(undefined)
}
}}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
>
Clear filters
</div>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
export { DataGridColumnFilter, type DataGridColumnFilterProps }
@@ -0,0 +1,347 @@
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import { Column } from "@tanstack/react-table"
import { cn } from "@authportal/ui/lib/utils"
import { Button } from "@authportal/ui/components/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@authportal/ui/components/dropdown-menu"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps<
TData,
TValue,
> extends HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
}
function DataGridColumnHeaderInner<TData, TValue>({
column,
title,
icon,
className,
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props, recordCount } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder
const columnVisibilityKey =
props.tableLayout?.columnsVisibility && visibility
? JSON.stringify(table.getState().columnVisibility)
: ""
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const canSort = column.getCanSort()
const canPin = column.getCanPin()
const canResize = column.getCanResize()
const columnIndex = columnOrder.indexOf(column.id)
const canMoveLeft = columnIndex > 0
const canMoveRight = columnIndex < columnOrder.length - 1
const handleSort = () => {
if (isSorted === "asc") {
column.toggleSorting(true)
} else if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}
const headerLabelClassName = cn(
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
className
)
const headerButtonClassName = cn(
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" aria-hidden="true" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
))
const hasControls =
props.tableLayout?.columnsMovable ||
(props.tableLayout?.columnsVisibility && visibility) ||
(props.tableLayout?.columnsPinnable && canPin) ||
filter
const menuItems = useMemo(() => {
const items: ReactNode[] = []
let hasPreviousSection = false
// Filter section
if (filter) {
items.push(
<DropdownMenuGroup key="group-filter">
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
</DropdownMenuGroup>
)
hasPreviousSection = true
}
// Sort section
if (canSort) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-sort" />)
}
items.push(
<DropdownMenuItem
key="sort-asc"
onClick={() => {
if (isSorted === "asc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}}
disabled={!canSort}
>
<ArrowUpIcon className="size-3.5!" />
<span className="grow">Asc</span>
{isSorted === "asc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="sort-desc"
onClick={() => {
if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(true)
}
}}
disabled={!canSort}
>
<ArrowDownIcon className="size-3.5!" />
<span className="grow">Desc</span>
{isSorted === "desc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Pin section
if (props.tableLayout?.columnsPinnable && canPin) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-pin" />)
}
items.push(
<DropdownMenuItem
key="pin-left"
onClick={() => column.pin(isPinned === "left" ? false : "left")}
>
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to left</span>
{isPinned === "left" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="pin-right"
onClick={() => column.pin(isPinned === "right" ? false : "right")}
>
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to right</span>
{isPinned === "right" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Move section
if (props.tableLayout?.columnsMovable) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-move" />)
}
items.push(
<DropdownMenuItem
key="move-left"
onClick={() => {
if (columnIndex > 0) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex - 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveLeft || isPinned !== false}
>
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Left</span>
</DropdownMenuItem>,
<DropdownMenuItem
key="move-right"
onClick={() => {
if (columnIndex < columnOrder.length - 1) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex + 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveRight || isPinned !== false}
>
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Right</span>
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Visibility section
if (props.tableLayout?.columnsVisibility && visibility) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-visibility" />)
}
items.push(
<DropdownMenuSub key="visibility">
<DropdownMenuSubTrigger>
<Settings2Icon className="size-3.5!" />
<span>Columns</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
className="capitalize"
>
{getColumnHeaderLabel(col)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filter,
canSort,
isSorted,
column,
props.tableLayout?.columnsPinnable,
props.tableLayout?.columnsMovable,
props.tableLayout?.columnsVisibility,
canPin,
isPinned,
canMoveLeft,
canMoveRight,
visibility,
table,
columnIndex,
columnOrder,
columnVisibilityKey, // Needed to update checkbox states when visibility changes
])
if (hasControls) {
return (
<div className="-ms-2 flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
}
/>
<DropdownMenuContent className="w-40" align="start">
{menuItems}
</DropdownMenuContent>
</DropdownMenu>
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
<Button
size="icon-sm"
variant="ghost"
className="rounded-lg -me-1 size-7"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
)}
</div>
)
}
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return (
<div className="-ms-2 flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
onClick={handleSort}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
</div>
)
}
return (
<div className={headerLabelClassName}>
{icon && icon}
{resolvedTitle}
</div>
)
}
const DataGridColumnHeader = memo(
DataGridColumnHeaderInner
) as typeof DataGridColumnHeaderInner
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
@@ -0,0 +1,53 @@
"use client"
import { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { Table } from "@tanstack/react-table"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@authportal/ui/components/dropdown-menu"
function DataGridColumnVisibility<TData>({
table,
trigger,
}: {
table: Table<TData>
trigger: ReactElement<Record<string, unknown>>
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnHeaderLabel(column)}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataGridColumnVisibility }
@@ -0,0 +1,221 @@
import React, { ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@authportal/ui/lib/utils"
import { Button } from "@authportal/ui/components/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@authportal/ui/components/select"
import { Skeleton } from "@authportal/ui/components/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
interface DataGridPaginationProps {
sizes?: number[]
sizesInfo?: string
sizesLabel?: string
sizesDescription?: string
sizesSkeleton?: ReactNode
more?: boolean
moreLimit?: number
info?: string
infoSkeleton?: ReactNode
className?: string
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
ellipsisText?: string
}
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
ellipsisText: "...",
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo
const paginationInfo = mergedProps.info
? mergedProps.info
.replaceAll("{from}", from.toString())
.replaceAll("{to}", to.toString())
.replaceAll("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
// Pagination limit logic
const paginationMoreLimit = mergedProps.moreLimit || 5
// Determine the start and end of the pagination group
const currentGroupStart =
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
const currentGroupEnd = Math.min(
currentGroupStart + paginationMoreLimit,
pageCount
)
// Render page buttons based on the current group
const renderPageButtons = () => {
const buttons = []
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
buttons.push(
<Button
key={i}
size="icon-sm"
variant="ghost"
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
onClick={() => {
if (pageIndex !== i) {
table.setPageIndex(i)
}
}}
>
{i + 1}
</Button>
)
}
return buttons
}
// Render a "previous" ellipsis button if there are previous pages to show
const renderEllipsisPrevButton = () => {
if (currentGroupStart > 0) {
return (
<Button
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
onClick={() => table.setPageIndex(currentGroupStart - 1)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
// Render a "next" ellipsis button if there are more pages to show after the current group
const renderEllipsisNextButton = () => {
if (currentGroupEnd < pageCount) {
return (
<Button
className={btnBaseClasses}
variant="ghost"
size="icon-sm"
onClick={() => table.setPageIndex(currentGroupEnd)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
return (
<div
data-slot="data-grid-pagination"
className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps.className
)}
>
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps.sizesSkeleton
) : (
<>
<div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel}
</div>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
const newPageSize = Number(value)
table.setPageSize(newPageSize)
}}
>
<SelectTrigger className="w-16" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
{mergedProps.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
{isLoading ? (
mergedProps.infoSkeleton
) : (
<>
<div className="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1">
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">
{mergedProps.previousPageLabel}
</span>
<ChevronLeftIcon className="size-4" />
</Button>
{renderEllipsisPrevButton()}
{renderPageButtons()}
{renderEllipsisNextButton()}
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">{mergedProps.nextPageLabel}</span>
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}
export { DataGridPagination, type DataGridPaginationProps }
@@ -0,0 +1,426 @@
"use client"
import {
PointerEvent,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@authportal/ui/lib/utils"
const MIN_THUMB_SIZE = 24
const FALLBACK_SCROLLBAR_SIZE = 12
const INITIAL_METRICS = {
hasVerticalOverflow: false,
headerHeight: 0,
horizontalScrollbarSize: 0,
thumbHeight: 0,
thumbTop: 0,
trackHeight: 0,
} as const
const SCROLLBAR_CLASSNAME =
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = {
hasVerticalOverflow: boolean
headerHeight: number
horizontalScrollbarSize: number
thumbHeight: number
thumbTop: number
trackHeight: number
}
type ObservedElements = {
header: HTMLElement | null
horizontalScrollbar: HTMLElement | null
table: HTMLElement | null
tableViewport: HTMLElement | null
}
type DataGridScrollAreaProps = Omit<
ScrollAreaPrimitive.Root.Props,
"children"
> & {
children: ReactNode
orientation?: DataGridScrollAreaOrientation
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
return (
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
next.headerHeight === prev.headerHeight &&
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
next.thumbHeight === prev.thumbHeight &&
next.thumbTop === prev.thumbTop &&
next.trackHeight === prev.trackHeight
)
}
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
element.style.setProperty(
"--data-grid-scrollbar-header-height",
`${metrics.headerHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-height",
`${metrics.thumbHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-top",
`${metrics.thumbTop}px`
)
element.style.setProperty(
"--data-grid-scrollbar-track-height",
`${metrics.trackHeight}px`
)
}
function DataGridScrollArea({
children,
className,
orientation = "both",
...props
}: DataGridScrollAreaProps) {
const { props: dataGridProps } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
pointerId: number
startScrollTop: number
startY: number
} | null>(null)
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
const observedElementsRef = useRef<ObservedElements>({
header: null,
horizontalScrollbar: null,
table: null,
tableViewport: null,
})
const showHorizontal = orientation !== "vertical"
const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false)
const clearDragState = useCallback(() => {
dragRef.current = null
document.body.style.userSelect = ""
document.body.style.webkitUserSelect = ""
}, [])
const resetMetrics = useCallback(() => {
const container = containerRef.current
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
applyMetrics(container, INITIAL_METRICS)
metricsRef.current = INITIAL_METRICS
}
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
}, [])
const syncCustomVerticalScrollbar = useCallback(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport || !usesCustomVerticalScrollbar) {
resetMetrics()
return
}
const { header, horizontalScrollbar } = observedElementsRef.current
const headerHeight = header?.getBoundingClientRect().height ?? 0
const viewportHeight = viewport.clientHeight
const viewportWidth = viewport.clientWidth
const scrollHeight = viewport.scrollHeight
const scrollWidth = viewport.scrollWidth
const hasHorizontalOverflow =
showHorizontal && scrollWidth > viewportWidth + 0.5
const horizontalScrollbarSize = hasHorizontalOverflow
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
: 0
const trackHeight = Math.max(
0,
viewportHeight - headerHeight - horizontalScrollbarSize
)
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
let nextMetrics: ScrollbarMetrics
if (trackHeight === 0 || maxScroll === 0) {
nextMetrics = {
hasVerticalOverflow: false,
headerHeight,
horizontalScrollbarSize,
thumbHeight: trackHeight,
thumbTop: 0,
trackHeight,
}
} else {
const bodyContentHeight = Math.max(
trackHeight,
scrollHeight - headerHeight
)
const thumbHeight = clamp(
trackHeight * (trackHeight / bodyContentHeight),
MIN_THUMB_SIZE,
trackHeight
)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const thumbTop =
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
nextMetrics = {
hasVerticalOverflow: true,
headerHeight,
horizontalScrollbarSize,
thumbHeight,
thumbTop,
trackHeight,
}
}
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
applyMetrics(container, nextMetrics)
metricsRef.current = nextMetrics
}
setHasCustomVerticalOverflow((prev) =>
prev === nextMetrics.hasVerticalOverflow
? prev
: nextMetrics.hasVerticalOverflow
)
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
useEffect(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport) return
if (!usesCustomVerticalScrollbar) {
resetMetrics()
return
}
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
let frame = 0
const scheduleSync = () => {
cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
}
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleSync)
observer?.observe(viewport)
observedElementsRef.current.header &&
observer?.observe(observedElementsRef.current.header)
observedElementsRef.current.table &&
observer?.observe(observedElementsRef.current.table)
observedElementsRef.current.tableViewport &&
observer?.observe(observedElementsRef.current.tableViewport)
return () => {
cancelAnimationFrame(frame)
observer?.disconnect()
viewport.removeEventListener("scroll", scheduleSync)
clearDragState()
}
}, [
clearDragState,
resetMetrics,
syncCustomVerticalScrollbar,
usesCustomVerticalScrollbar,
])
const scrollToThumbOffset = (nextThumbTop: number) => {
const viewport = viewportRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport) return
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
if (maxScroll === 0 || maxThumbTop === 0) {
viewport.scrollTop = 0
return
}
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
viewport.scrollTop = ratio * maxScroll
}
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
if (!viewport) return
event.preventDefault()
event.stopPropagation()
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = {
pointerId: event.pointerId,
startScrollTop: viewport.scrollTop,
startY: event.clientY,
}
document.body.style.userSelect = "none"
document.body.style.webkitUserSelect = "none"
}
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
const dragState = dragRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
return
}
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
if (maxThumbTop === 0 || maxScroll === 0) return
const deltaY = event.clientY - dragState.startY
const nextScrollTop =
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
}
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return
clearDragState()
}
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const { thumbHeight } = metricsRef.current
if (event.target !== event.currentTarget) return
event.preventDefault()
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
const offsetY = event.clientY - rect.top - thumbHeight / 2
scrollToThumbOffset(offsetY)
}
return (
<div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className="size-full"
>
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{showHorizontal && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className={SCROLLBAR_CLASSNAME}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
{showVertical && !usesCustomVerticalScrollbar && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className={SCROLLBAR_CLASSNAME}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
</ScrollAreaPrimitive.Root>
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<div
className="pointer-events-auto relative h-full w-2 touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-2",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full"
)}
onLostPointerCapture={clearDragState}
onPointerCancel={handleThumbPointerUp}
onPointerDown={handleThumbPointerDown}
onPointerMove={handleThumbPointerMove}
onPointerUp={handleThumbPointerUp}
/>
</div>
</div>
)}
</div>
)
}
export { DataGridScrollArea }
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
@@ -0,0 +1,302 @@
import {
createContext,
CSSProperties,
ReactNode,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
import { cn } from "@authportal/ui/lib/utils"
import { Button } from "@authportal/ui/components/button"
import { GripHorizontalIcon } from "lucide-react"
// Context to share sortable listeners from row to handle
type SortableContextValue = ReturnType<typeof useSortable>
const SortableRowContext = createContext<Pick<
SortableContextValue,
"attributes" | "listeners"
> | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) {
const context = useContext(SortableRowContext)
if (!context) {
// Fallback if context is not available (shouldn't happen in normal usage)
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
disabled
>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
const {
transform,
transition,
setNodeRef,
isDragging,
attributes,
listeners,
} = useSortable({
id: row.id,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
}
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
return (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
</DataGridTableBodyRow>
</SortableRowContext.Provider>
)
}
function DataGridTableDndRows<TData>({
handleDragEnd,
dataIds,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingRow) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingRow])
const modifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
}) => {
if (!tableContainerRef.current || !draggingNodeRect) {
return transform
}
const containerRect = tableContainerRef.current.getBoundingClientRect()
const { x, y } = transform
const minX = containerRect.left - draggingNodeRect.left
const maxX = containerRect.right - draggingNodeRect.right
const minY = containerRect.top - draggingNodeRect.top
const maxY = containerRect.bottom - draggingNodeRect.bottom
return {
...transform,
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
return [restrictToVerticalAxis, restrictToTableContainer]
}, [])
return (
<DndContext
id={useId()}
collisionDetection={closestCenter}
modifiers={modifiers}
onDragCancel={() => setIsDraggingRow(false)}
onDragEnd={(event) => {
setIsDraggingRow(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingRow(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={tableContainerRef}
className={
isDraggingRow
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
{headerGroup.headers.map((header, index) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row: Row<TData>) => {
return <DataGridTableDndRow row={row} key={row.id} />
})}
</SortableContext>
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
@@ -0,0 +1,319 @@
"use client"
import {
CSSProperties,
Fragment,
ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
Modifier,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
Cell,
flexRender,
Header,
HeaderGroup,
Row,
} from "@tanstack/react-table"
import { Button } from "@authportal/ui/components/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData>({
header,
}: {
header: Header<TData, unknown>
}) {
const { props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
const canOrder =
(column.columnDef as { enableColumnOrdering?: boolean })
.enableColumnOrdering !== false
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: header.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
whiteSpace: "nowrap",
width: props.tableLayout?.columnsResizable
? `calc(var(--header-${header.id}-size) * 1px)`
: header.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableHeadRowCell
header={header}
dndStyle={style}
dndRef={setNodeRef}
>
<div className="flex items-center justify-start gap-0.5">
{canOrder && (
<Button
size="icon-sm"
variant="ghost"
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<div className="grow">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</div>
</DataGridTableHeadRowCell>
)
}
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
width: props.tableLayout?.columnsResizable
? `calc(var(--col-${cell.column.id}-size) * 1px)`
: cell.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
}
function DataGridTableDnd<TData>({
handleDragEnd,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingColumn) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const modifiers = useMemo(() => {
const restrictToTableBounds: Modifier = ({
draggingNodeRect,
transform,
}) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return [restrictToTableBounds]
}, [])
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={modifiers}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingColumn(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={containerRef}
className={
isDraggingColumn
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
))}
</SortableContext>
</DataGridTableBodyRow>
{row.getIsExpanded() && (
<DataGridTableBodyRowExpandded row={row} />
)}
</Fragment>
)
})
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
@@ -0,0 +1,597 @@
import {
CSSProperties,
memo,
ReactNode,
useCallback,
useEffect,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridTableMergedHeaderGroups,
getDataGridTableRowSections,
getPinningStyles,
hasDataGridTableRightPinnedColumns,
} from "@/components/reui/data-grid/data-grid-table"
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
import {
useVirtualizer,
VirtualItem,
Virtualizer,
VirtualizerOptions,
} from "@tanstack/react-virtual"
import { cn } from "@authportal/ui/lib/utils"
import { Spinner } from "@authportal/ui/components/spinner"
type DataGridTableVirtualScrollElements = {
containerElement: HTMLDivElement | null
scrollElement: HTMLElement | null
}
type DataGridTableVirtualizerInstance = Virtualizer<
HTMLElement,
HTMLTableRowElement
>
type DataGridTableVirtualizerOptions<TData> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
> & {
estimateSize?: (index: number, row: Row<TData>) => number
getItemKey?: (index: number, row: Row<TData>) => string | number
getScrollElement?: (
elements: DataGridTableVirtualScrollElements
) => HTMLElement | null
}
interface DataGridTableVirtualProps<TData> {
height?: number | string
estimateSize?: number
overscan?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
isFetchingMore?: boolean
hasMore?: boolean
fetchMoreOffset?: number
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
}
interface VirtualBodyProps<TData> {
table: Table<TData>
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
virtualItems: VirtualItem[]
totalSize: number
isVirtualizationEnabled: boolean
isInfiniteMode: boolean
isFetchingMore: boolean
hasMore?: boolean
loadingMoreMessage: ReactNode
allRowsLoadedMessage: ReactNode
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
column,
}: {
column: Column<TData>
}) {
const { props } = useDataGrid()
const isPinned = column.getIsPinned()
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
const isFirstRightPinned =
isPinned === "right" && column.getIsFirstColumn("right")
return (
<td
aria-hidden="true"
style={{
...(props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
getPinningStyles(column)),
...(props.tableLayout?.columnsResizable && {
width: `calc(var(--col-${column.id}-size) * 1px)`,
}),
}}
data-pinned={isPinned || undefined}
data-last-col={
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
}
className={cn(
"p-0",
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
)}
/>
)
}
function DataGridTableVirtualUtilityRow<TData>({
table,
children,
centerCellClassName,
centerCellStyle,
rowClassName,
ariaHidden,
}: {
table: Table<TData>
children: ReactNode
centerCellClassName?: string
centerCellStyle?: CSSProperties
rowClassName?: string
ariaHidden?: boolean
}) {
const { props } = useDataGrid()
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getRightVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
{leftVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
<td
colSpan={Math.max(centerVisibleColumns.length, 1)}
className={centerCellClassName}
style={centerCellStyle}
>
{children}
</td>
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
{rightVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
</tr>
)
}
function DataGridTableVirtualSpacer<TData>({
table,
height,
}: {
table: Table<TData>
height: number
}) {
if (height <= 0) return null
return (
<DataGridTableVirtualUtilityRow
table={table}
ariaHidden
centerCellClassName="p-0"
centerCellStyle={{ height, padding: 0 }}
>
{null}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualStatusRow<TData>({
table,
children,
className,
}: {
table: Table<TData>
children: ReactNode
className?: string
}) {
return (
<DataGridTableVirtualUtilityRow
table={table}
centerCellClassName={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualBody<TData>({
table,
topRows,
centerRows,
bottomRows,
virtualItems,
totalSize,
isVirtualizationEnabled,
isInfiniteMode,
isFetchingMore,
hasMore,
loadingMoreMessage,
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) return <DataGridTableEmpty />
const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
const leadingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? (virtualItems[0]?.start ?? 0)
: 0
const trailingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? Math.max(
0,
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
)
: 0
const renderedRows: ReactNode[] = []
topRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
}
/>
)
})
if (isVirtualizationEnabled) {
if (leadingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-start"
table={table}
height={leadingSpacerHeight}
/>
)
}
virtualItems.forEach((virtualRow) => {
const row = centerRows[virtualRow.index]
if (!row) return
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
rowRef={measureRowRef}
/>
)
})
if (trailingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-end"
table={table}
height={trailingSpacerHeight}
/>
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
})
}
if (showFetchingRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
if (showCompleteRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-complete"
table={table}
className="py-3 text-xs"
>
{allRowsLoadedMessage}
</DataGridTableVirtualStatusRow>
)
}
bottomRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === 0 && (topRows.length > 0 || hasMiddleSection)
? "bottom"
: undefined
}
/>
)
})
return <>{renderedRows}</>
}
/**
* Memoized virtual body: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData>({
height,
estimateSize = 48,
overscan = 10,
footerContent,
renderHeader = true,
onFetchMore,
isFetchingMore = false,
hasMore,
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
)
const isInfiniteMode = typeof onFetchMore === "function"
const [viewportElements, setViewportElements] =
useState<DataGridTableVirtualScrollElements>({
containerElement: null,
scrollElement: null,
})
const {
estimateSize: customEstimateSize,
getItemKey: customGetItemKey,
getScrollElement: customGetScrollElement,
measureElement: customMeasureElement,
overscan: customOverscan,
...virtualizerOptionsRest
} = virtualizerOptions ?? {}
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
containerElement: node,
scrollElement:
(node?.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement | null) ?? node,
})
}, [])
const usesExternalScrollArea =
viewportElements.scrollElement !== null &&
viewportElements.scrollElement !== viewportElements.containerElement
const resolveScrollElement = useCallback(() => {
if (customGetScrollElement) {
return customGetScrollElement(viewportElements)
}
return viewportElements.scrollElement
}, [customGetScrollElement, viewportElements])
const resolveItemKey = useCallback(
(index: number) => {
const row = centerRows[index]
if (!row) return index
return customGetItemKey?.(index, row) ?? row.id ?? index
},
[centerRows, customGetItemKey]
)
const resolveEstimateSize = useCallback(
(index: number) => {
const row = centerRows[index]
return row
? (customEstimateSize?.(index, row) ?? estimateSize)
: estimateSize
},
[centerRows, customEstimateSize, estimateSize]
)
const virtualizer = useVirtualizer({
count: centerRows.length,
getScrollElement: resolveScrollElement,
getItemKey: resolveItemKey,
estimateSize: resolveEstimateSize,
overscan: customOverscan ?? overscan,
measureElement: customMeasureElement,
...virtualizerOptionsRest,
}) as DataGridTableVirtualizerInstance
const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems()
: []
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
const measureRowRef =
isVirtualizationEnabled && customMeasureElement
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
useEffect(() => {
if (
!isVirtualizationEnabled ||
!isInfiniteMode ||
hasMore === false ||
isFetchingMore
) {
return
}
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
onFetchMore?.()
}
}, [
centerRows.length,
hasMore,
isFetchingMore,
isInfiniteMode,
isVirtualizationEnabled,
onFetchMore,
resolvedFetchMoreOffset,
virtualItems,
])
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: { height, overflow: "auto", position: "relative" }
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup) => (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedVirtualBody
table={table}
topRows={topRows}
centerRows={centerRows}
bottomRows={bottomRows}
virtualItems={virtualItems}
totalSize={totalSize}
isVirtualizationEnabled={isVirtualizationEnabled}
isInfiniteMode={isInfiniteMode}
isFetchingMore={isFetchingMore}
hasMore={hasMore}
loadingMoreMessage={loadingMoreMessage}
allRowsLoadedMessage={allRowsLoadedMessage}
measureRowRef={measureRowRef}
/>
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
)
}
export { DataGridTableVirtual }
export type {
DataGridTableVirtualProps,
DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,266 @@
import { createContext, ReactNode, useContext, useMemo } from "react"
import {
Column,
ColumnFiltersState,
RowData,
SortingState,
Table,
} from "@tanstack/react-table"
import { cn } from "@authportal/ui/lib/utils"
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
headerTitle?: string
headerClassName?: string
cellClassName?: string
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
autoSize?: boolean
}
}
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
export function getColumnHeaderLabel<TData, TValue>(
column: Column<TData, TValue>
): string {
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
if (typeof meta?.headerTitle === "string") return meta.headerTitle
const defHeader = column.columnDef.header
if (typeof defHeader === "string") return defHeader
return String(column.id)
}
export type DataGridApiFetchParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
filters?: ColumnFiltersState
searchQuery?: string
}
export type DataGridApiResponse<T> = {
data: T[]
empty: boolean
pagination: {
total: number
page: number
}
}
export interface DataGridContextProps<TData extends object> {
props: DataGridProps<TData>
table: Table<TData>
recordCount: number
isLoading: boolean
}
export type DataGridRequestParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
columnFilters?: ColumnFiltersState
}
export interface DataGridProps<TData extends object> {
className?: string
table?: Table<TData>
recordCount: number
children?: ReactNode
onRowClick?: (row: TData) => void
isLoading?: boolean
loadingMode?: "skeleton" | "spinner"
loadingMessage?: ReactNode | string
fetchingMoreMessage?: ReactNode | string
allRowsLoadedMessage?: ReactNode | string
emptyMessage?: ReactNode | string
tableLayout?: {
dense?: boolean
cellBorder?: boolean
rowBorder?: boolean
rowRounded?: boolean
stripped?: boolean
headerBackground?: boolean
footerBackground?: boolean
headerBorder?: boolean
headerSticky?: boolean
width?: "auto" | "fixed"
columnsVisibility?: boolean
columnsResizable?: boolean
columnsResizeMode?: "onChange" | "onEnd"
columnsPinnable?: boolean
columnsMovable?: boolean
columnsDraggable?: boolean
rowsDraggable?: boolean
rowsPinnable?: boolean
}
tableClassNames?: {
base?: string
header?: string
headerRow?: string
headerSticky?: string
body?: string
bodyRow?: string
footer?: string
edgeCell?: string
}
}
const DataGridContext = createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DataGridContextProps<any> | undefined
>(undefined)
function useDataGrid() {
const context = useContext(DataGridContext)
if (!context) {
throw new Error("useDataGrid must be used within a DataGridProvider")
}
return context
}
function DataGridProvider<TData extends object>({
children,
table,
...props
}: DataGridProps<TData> & { table: Table<TData> }) {
const tableState = table.getState()
const resolvedColumnsResizeMode =
props.tableLayout?.columnsResizeMode ?? "onEnd"
// Keep resize mode aligned with the DataGrid contract every render so
// consumer-level useReactTable options cannot flip it back between drags.
if (props.tableLayout?.columnsResizable) {
table.options.columnResizeMode = resolvedColumnsResizeMode
}
// Memoize context value so consumers don't re-render during column resize.
// Column sizing state is intentionally excluded from deps -- CSS variables
// on the <table> element handle width updates without React re-renders.
const value = useMemo(
() => ({
props,
table,
recordCount: props.recordCount,
isLoading: props.isLoading || false,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
table,
props.recordCount,
props.isLoading,
props.loadingMode,
props.loadingMessage,
props.fetchingMoreMessage,
props.allRowsLoadedMessage,
props.emptyMessage,
props.onRowClick,
props.className,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableLayout),
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableClassNames),
tableState.sorting,
tableState.pagination,
tableState.columnFilters,
tableState.rowSelection,
tableState.expanded,
tableState.columnVisibility,
tableState.columnOrder,
tableState.columnPinning,
tableState.globalFilter,
]
)
return (
<DataGridContext.Provider value={value}>
{children}
</DataGridContext.Provider>
)
}
function DataGrid<TData extends object>({
children,
table,
...props
}: DataGridProps<TData>) {
const defaultProps: Partial<DataGridProps<TData>> = {
loadingMode: "skeleton",
tableLayout: {
dense: false,
cellBorder: false,
rowBorder: true,
rowRounded: false,
stripped: false,
headerSticky: false,
headerBackground: false,
footerBackground: false,
headerBorder: true,
width: "fixed",
columnsVisibility: false,
columnsResizable: false,
columnsResizeMode: "onEnd",
columnsPinnable: false,
columnsMovable: false,
columnsDraggable: false,
rowsDraggable: false,
rowsPinnable: false,
},
tableClassNames: {
base: "",
header: "",
headerRow: "",
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
body: "",
bodyRow: "",
footer: "",
edgeCell: "",
},
}
const mergedProps: DataGridProps<TData> = {
...defaultProps,
...props,
tableLayout: {
...defaultProps.tableLayout,
...(props.tableLayout || {}),
},
tableClassNames: {
...defaultProps.tableClassNames,
...(props.tableClassNames || {}),
},
}
// Ensure table is provided
if (!table) {
throw new Error('DataGrid requires a "table" prop')
}
return (
<DataGridProvider table={table} {...mergedProps}>
{children}
</DataGridProvider>
)
}
function DataGridContainer({
children,
className,
border: _border = true,
}: {
children: ReactNode
className?: string
border?: boolean
}) {
return (
<div
data-slot="data-grid"
className={cn("w-full overflow-hidden", className)}
>
{children}
</div>
)
}
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
File diff suppressed because it is too large Load Diff
+175
View File
@@ -0,0 +1,175 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
/**
* CSS variable architecture for FramePanel theming:
*
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
* border-(--frame-panel-border-color). This means:
*
* - variant="inverse" overrides those vars on Frame all panels pick it up
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
* which wins over bg-(--frame-panel-bg) by Tailwind source order no
* :not() or !important needed
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
],
{
variants: {
variant: {
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
},
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
},
stacked: {
true: [
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
"*:has-[+[data-slot=frame-panel]]:before:hidden",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
],
false: [
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
defaultVariants: {
variant: "default",
spacing: "default",
stacked: false,
dense: false,
},
}
)
function Frame({
className,
variant,
spacing,
stacked,
dense,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
return (
<div
className={cn(
frameVariants({ variant, spacing, stacked, dense }),
className
)}
data-slot="frame"
data-spacing={spacing}
{...props}
/>
)
}
function FramePanel({
className,
fit,
...props
}: React.ComponentProps<"div"> & { fit?: boolean }) {
return (
<div
className={cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order - no ! needed.
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
)}
data-slot="frame-panel"
{...props}
/>
)
}
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
return (
<header
className={cn(
"flex flex-col gap-(--frame-panel-header-gap) px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
className
)}
data-slot="frame-panel-header"
{...props}
/>
)
}
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("text-sm font-semibold", className)}
data-slot="frame-panel-title"
{...props}
/>
)
}
function FrameDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="frame-panel-description"
{...props}
/>
)
}
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
return (
<footer
className={cn(
"flex flex-col gap-(--frame-panel-footer-gap) px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
className
)}
data-slot="frame-panel-footer"
{...props}
/>
)
}
export {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
FrameFooter,
frameVariants,
}
@@ -0,0 +1,9 @@
import type { SVGProps } from "react";
const Apple = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlSpace="preserve" viewBox="0 0 814 1000">
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z" />
</svg>
);
export { Apple };
@@ -0,0 +1,12 @@
import type { SVGProps } from "react";
const AppleDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlSpace="preserve" viewBox="0 0 814 1000">
<path
fill="#fff"
d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"
/>
</svg>
);
export { AppleDark };
+243
View File
@@ -0,0 +1,243 @@
import type { SVGProps } from "react";
const Google = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
xmlnsXlink="http://www.w3.org/1999/xlink"
xmlSpace="preserve"
overflow="hidden"
viewBox="0 0 268.152 273.883"
>
<defs>
<linearGradient id="a">
<stop offset="0" stopColor="#0fbc5c" />
<stop offset="1" stopColor="#0cba65" />
</linearGradient>
<linearGradient id="g">
<stop offset=".231" stopColor="#0fbc5f" />
<stop offset=".312" stopColor="#0fbc5f" />
<stop offset=".366" stopColor="#0fbc5e" />
<stop offset=".458" stopColor="#0fbc5d" />
<stop offset=".54" stopColor="#12bc58" />
<stop offset=".699" stopColor="#28bf3c" />
<stop offset=".771" stopColor="#38c02b" />
<stop offset=".861" stopColor="#52c218" />
<stop offset=".915" stopColor="#67c30f" />
<stop offset="1" stopColor="#86c504" />
</linearGradient>
<linearGradient id="h">
<stop offset=".142" stopColor="#1abd4d" />
<stop offset=".248" stopColor="#6ec30d" />
<stop offset=".312" stopColor="#8ac502" />
<stop offset=".366" stopColor="#a2c600" />
<stop offset=".446" stopColor="#c8c903" />
<stop offset=".54" stopColor="#ebcb03" />
<stop offset=".616" stopColor="#f7cd07" />
<stop offset=".699" stopColor="#fdcd04" />
<stop offset=".771" stopColor="#fdce05" />
<stop offset=".861" stopColor="#ffce0a" />
</linearGradient>
<linearGradient id="f">
<stop offset=".316" stopColor="#ff4c3c" />
<stop offset=".604" stopColor="#ff692c" />
<stop offset=".727" stopColor="#ff7825" />
<stop offset=".885" stopColor="#ff8d1b" />
<stop offset="1" stopColor="#ff9f13" />
</linearGradient>
<linearGradient id="b">
<stop offset=".231" stopColor="#ff4541" />
<stop offset=".312" stopColor="#ff4540" />
<stop offset=".458" stopColor="#ff4640" />
<stop offset=".54" stopColor="#ff473f" />
<stop offset=".699" stopColor="#ff5138" />
<stop offset=".771" stopColor="#ff5b33" />
<stop offset=".861" stopColor="#ff6c29" />
<stop offset="1" stopColor="#ff8c18" />
</linearGradient>
<linearGradient id="d">
<stop offset=".408" stopColor="#fb4e5a" />
<stop offset="1" stopColor="#ff4540" />
</linearGradient>
<linearGradient id="c">
<stop offset=".132" stopColor="#0cba65" />
<stop offset=".21" stopColor="#0bb86d" />
<stop offset=".297" stopColor="#09b479" />
<stop offset=".396" stopColor="#08ad93" />
<stop offset=".477" stopColor="#0aa6a9" />
<stop offset=".568" stopColor="#0d9cc6" />
<stop offset=".667" stopColor="#1893dd" />
<stop offset=".769" stopColor="#258bf1" />
<stop offset=".859" stopColor="#3086ff" />
</linearGradient>
<linearGradient id="e">
<stop offset=".366" stopColor="#ff4e3a" />
<stop offset=".458" stopColor="#ff8a1b" />
<stop offset=".54" stopColor="#ffa312" />
<stop offset=".616" stopColor="#ffb60c" />
<stop offset=".771" stopColor="#ffcd0a" />
<stop offset=".861" stopColor="#fecf0a" />
<stop offset=".915" stopColor="#fecf08" />
<stop offset="1" stopColor="#fdcd01" />
</linearGradient>
<linearGradient
xlinkHref="#a"
id="s"
x1="219.7"
x2="254.467"
y1="329.535"
y2="329.535"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#b"
id="m"
cx="109.627"
cy="135.862"
r="71.46"
fx="109.627"
fy="135.862"
gradientTransform="matrix(-1.93688 1.043 1.45573 2.55542 290.525 -400.634)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#c"
id="n"
cx="45.259"
cy="279.274"
r="71.46"
fx="45.259"
fy="279.274"
gradientTransform="matrix(-3.5126 -4.45809 -1.69255 1.26062 870.8 191.554)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#d"
id="l"
cx="304.017"
cy="118.009"
r="47.854"
fx="304.017"
fy="118.009"
gradientTransform="matrix(2.06435 0 0 2.59204 -297.679 -151.747)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#e"
id="o"
cx="181.001"
cy="177.201"
r="71.46"
fx="181.001"
fy="177.201"
gradientTransform="matrix(-.24858 2.08314 2.96249 .33417 -255.146 -331.164)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#f"
id="p"
cx="207.673"
cy="108.097"
r="41.102"
fx="207.673"
fy="108.097"
gradientTransform="matrix(-1.2492 1.34326 -3.89684 -3.4257 880.501 194.905)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#g"
id="r"
cx="109.627"
cy="135.862"
r="71.46"
fx="109.627"
fy="135.862"
gradientTransform="matrix(-1.93688 -1.043 1.45573 -2.55542 290.525 838.683)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#h"
id="j"
cx="154.87"
cy="145.969"
r="71.46"
fx="154.87"
fy="145.969"
gradientTransform="matrix(-.0814 -1.93722 2.92674 -.11625 -215.135 632.86)"
gradientUnits="userSpaceOnUse"
/>
<filter
id="q"
width="1.097"
height="1.116"
x="-.048"
y="-.058"
colorInterpolationFilters="sRGB"
>
<feGaussianBlur stdDeviation="1.701" />
</filter>
<filter
id="k"
width="1.033"
height="1.02"
x="-.017"
y="-.01"
colorInterpolationFilters="sRGB"
>
<feGaussianBlur stdDeviation=".242" />
</filter>
<clipPath id="i" clipPathUnits="userSpaceOnUse">
<path d="M371.378 193.24H237.083v53.438h77.167c-1.241 7.563-4.026 15.003-8.105 21.786-4.674 7.773-10.451 13.69-16.373 18.196-17.74 13.498-38.42 16.258-52.783 16.258-36.283 0-67.283-23.286-79.285-54.928-.484-1.149-.805-2.335-1.197-3.507a81.115 81.115 0 0 1-4.101-25.448c0-9.226 1.569-18.057 4.43-26.398 11.285-32.897 42.985-57.467 80.179-57.467 7.481 0 14.685.884 21.517 2.648a77.668 77.668 0 0 1 33.425 18.25l40.834-39.712c-24.839-22.616-57.219-36.32-95.844-36.32-30.878 0-59.386 9.553-82.748 25.7-18.945 13.093-34.483 30.625-44.97 50.985-9.753 18.879-15.094 39.8-15.094 62.294 0 22.495 5.35 43.633 15.103 62.337v.126c10.302 19.857 25.368 36.954 43.678 49.988 15.997 11.386 44.68 26.551 84.031 26.551 22.63 0 42.687-4.051 60.375-11.644 12.76-5.478 24.065-12.622 34.301-21.804 13.525-12.132 24.117-27.139 31.347-44.404 7.23-17.265 11.097-36.79 11.097-57.957 0-9.858-.998-19.87-2.689-28.968Z" />
</clipPath>
</defs>
<g clipPath="url(#i)" transform="matrix(.95792 0 0 .98525 -90.174 -78.856)">
<path
fill="url(#j)"
d="M92.076 219.958c.148 22.14 6.501 44.983 16.117 63.424v.127c6.949 13.392 16.445 23.97 27.26 34.452l65.327-23.67c-12.36-6.235-14.246-10.055-23.105-17.026-9.054-9.066-15.802-19.473-20.004-31.677h-.17l.17-.127c-2.765-8.058-3.037-16.613-3.14-25.503Z"
filter="url(#k)"
/>
<path
fill="url(#l)"
d="M237.083 79.025c-6.456 22.526-3.988 44.421 0 57.161 7.457.006 14.64.888 21.45 2.647a77.662 77.662 0 0 1 33.424 18.25l41.88-40.726c-24.81-22.59-54.667-37.297-96.754-37.332Z"
filter="url(#k)"
/>
<path
fill="url(#m)"
d="M236.943 78.847c-31.67 0-60.91 9.798-84.871 26.359a145.533 145.533 0 0 0-24.332 21.15c-1.904 17.744 14.257 39.551 46.262 39.37 15.528-17.936 38.495-29.542 64.056-29.542l.07.002-1.044-57.335c-.048 0-.093-.004-.14-.004Z"
filter="url(#k)"
/>
<path
fill="url(#n)"
d="m341.475 226.379-28.268 19.285c-1.24 7.562-4.028 15.002-8.107 21.786-4.674 7.772-10.45 13.69-16.373 18.196-17.702 13.47-38.328 16.244-52.687 16.255-14.842 25.102-17.444 37.675 1.043 57.934 22.877-.016 43.157-4.117 61.046-11.796 12.931-5.551 24.388-12.792 34.761-22.097 13.706-12.295 24.442-27.503 31.769-45 7.327-17.497 11.245-37.282 11.245-58.734Z"
filter="url(#k)"
/>
<path
fill="#3086ff"
d="M234.996 191.21v57.498h136.006c1.196-7.874 5.152-18.064 5.152-26.5 0-9.858-.996-21.899-2.687-30.998Z"
filter="url(#k)"
/>
<path
fill="url(#o)"
d="M128.39 124.327c-8.394 9.119-15.564 19.326-21.249 30.364-9.753 18.879-15.094 41.83-15.094 64.324 0 .317.026.627.029.944 4.32 8.224 59.666 6.649 62.456 0-.004-.31-.039-.613-.039-.924 0-9.226 1.57-16.026 4.43-24.367 3.53-10.289 9.056-19.763 16.123-27.926 1.602-2.031 5.875-6.397 7.121-9.016.475-.997-.862-1.557-.937-1.908-.083-.393-1.876-.077-2.277-.37-1.275-.929-3.8-1.414-5.334-1.845-3.277-.921-8.708-2.953-11.725-5.06-9.536-6.658-24.417-14.612-33.505-24.216Z"
filter="url(#k)"
/>
<path
fill="url(#p)"
d="M162.099 155.857c22.112 13.301 28.471-6.714 43.173-12.977l-25.574-52.664a144.74 144.74 0 0 0-26.543 14.504c-12.316 8.512-23.192 18.9-32.176 30.72Z"
filter="url(#q)"
/>
<path
fill="url(#r)"
d="M171.099 290.222c-29.683 10.641-34.33 11.023-37.062 29.29a144.806 144.806 0 0 0 16.792 13.984c15.996 11.386 46.766 26.551 86.118 26.551.046 0 .09-.004.137-.004v-59.157l-.094.002c-14.736 0-26.512-3.843-38.585-10.527-2.977-1.648-8.378 2.777-11.123.799-3.786-2.729-12.9 2.35-16.183-.938Z"
filter="url(#k)"
/>
<path
fill="url(#s)"
d="M219.7 299.023v59.996c5.506.64 11.236 1.028 17.247 1.028 6.026 0 11.855-.307 17.52-.872v-59.748a105.119 105.119 0 0 1-17.477 1.461c-5.932 0-11.7-.686-17.29-1.865Z"
filter="url(#k)"
opacity=".5"
/>
</g>
</svg>
);
export { Google };
@@ -0,0 +1,37 @@
import type { SVGProps } from "react"
const OPENAI_WORDMARK_DARK_PATH = [
"M367.44 153.84c0 52.32 33.6 88.8 80.16 88.8 46.56 0 80.16-36.48 80.16-88.8s-33.6-88.8-80.16-88.8c-46.56 0-80.16 36.48-80.16 88.8Z",
"m129.6 0c0 37.44-20.4 61.68-49.44 61.68s-49.44-24.24-49.44-61.68 20.4-61.68 49.44-61.68 49.44 24.24 49.44 61.68Z",
"M614.27 242.64c35.28 0 55.44-29.76 55.44-65.52 0-35.76-20.16-65.52-55.44-65.52-16.32 0-28.32 6.48-36.24 15.84V114h-28.8v169.2h28.8v-56.4c7.92 9.36 19.92 15.84 36.24 15.84Z",
"m-36.96-69.12c0-23.76 13.44-36.72 31.2-36.72 20.88 0 32.16 16.32 32.16 40.32s-11.28 40.32-32.16 40.32c-17.76 0-31.2-13.2-31.2-36.48v-7.44Z",
"M747.65 242.64c25.2 0 45.12-13.2 54-35.28L776.93 198c-3.84 12.96-15.12 20.16-29.28 20.16-18.48 0-31.44-13.2-33.6-34.8h88.32v-9.6c0-34.56-19.44-62.16-55.92-62.16-36.48 0-60 28.56-60 65.52 0 38.88 25.2 65.52 61.2 65.52Z",
"m-1.44-106.8c18.24 0 26.88 12 27.12 25.92h-57.84c4.32-17.04 15.84-25.92 30.72-25.92Z",
"M823.98 240h28.8v-73.92c0-18 13.2-27.6 26.16-27.6 15.84 0 22.08 11.28 22.08 26.88V240h28.8v-83.04c0-27.12-15.84-45.36-42.24-45.36-16.32 0-27.6 7.44-34.8 15.84V114h-28.8v126Z",
"M1014.17 67.68 948.89 240h30.48l14.64-39.36h74.4l14.88 39.36h30.96l-65.28-172.32h-34.8Z",
"m16.8 34.08 27.36 72h-54.24l26.88-72Z",
"M1163.69 68.18h-30.72V240.5h30.72V68.18Z",
"M297.06 130.97a79.712 79.712 0 0 0-6.85-65.48c-17.46-30.4-52.56-46.04-86.84-38.68A79.747 79.747 0 0 0 143.24 0C108.2-.08 77.11 22.48 66.33 55.82a79.754 79.754 0 0 0-53.31 38.67c-17.59 30.32-13.58 68.54 9.92 94.54a79.712 79.712 0 0 0 6.85 65.48c17.46 30.4 52.56 46.04 86.84 38.68a79.687 79.687 0 0 0 60.13 26.8c35.06.09 66.16-22.49 76.94-55.86a79.754 79.754 0 0 0 53.31-38.67c17.57-30.32 13.55-68.51-9.94-94.51l-.01.02Z",
"M176.78 299.08a59.77 59.77 0 0 1-38.39-13.88c.49-.26 1.34-.73 1.89-1.07l63.72-36.8a10.36 10.36 0 0 0 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97Z",
"M47.94 244.05a59.71 59.71 0 0 1-7.15-40.18c.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83L129.87 266c-28.69 16.52-65.33 6.7-81.92-21.95h-.01Z",
"M31.17 104.96c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91L118.44 224c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89l.01-.01Z",
"m221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94a59.94 59.94 0 0 1-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06h-.01Z",
"m26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8a10.375 10.375 0 0 0-10.47 0l-77.79 44.92V92c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22a59.95 59.95 0 0 1 7.15 40.1h.02Z",
"m-168.51 55.43-26.94-15.55a.943.943 0 0 1-.52-.74V80.86c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07L116 72.67a10.344 10.344 0 0 0-5.24 9.06l-.04 89.79v.02Z",
"M125.35 140 160 119.99l34.65 20V180L160 200l-34.65-20v-40Z",
].join(" ")
const OpenaiWordmarkDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" viewBox="0 0 1180 320">
<g fill="#fff" clipPath="url(#a)">
<path d={OPENAI_WORDMARK_DARK_PATH} />
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h1180v320H0z" />
</clipPath>
</defs>
</svg>
)
export { OpenaiWordmarkDark }
@@ -0,0 +1,30 @@
import type { SVGProps } from "react"
const OPENAI_WORDMARK_LIGHT_PATH = [
"M367.44 153.84c0 52.32 33.6 88.8 80.16 88.8s80.16-36.48 80.16-88.8-33.6-88.8-80.16-88.8-80.16 36.48-80.16 88.8z",
"m129.6 0c0 37.44-20.4 61.68-49.44 61.68s-49.44-24.24-49.44-61.68 20.4-61.68 49.44-61.68 49.44 24.24 49.44 61.68z",
"M614.27 242.64c35.28 0 55.44-29.76 55.44-65.52s-20.16-65.52-55.44-65.52c-16.32 0-28.32 6.48-36.24 15.84V114h-28.8v169.2h28.8v-56.4c7.92 9.36 19.92 15.84 36.24 15.84z",
"m-36.96-69.12c0-23.76 13.44-36.72 31.2-36.72 20.88 0 32.16 16.32 32.16 40.32s-11.28 40.32-32.16 40.32c-17.76 0-31.2-13.2-31.2-36.48z",
"M747.65 242.64c25.2 0 45.12-13.2 54-35.28L776.93 198c-3.84 12.96-15.12 20.16-29.28 20.16-18.48 0-31.44-13.2-33.6-34.8h88.32v-9.6c0-34.56-19.44-62.16-55.92-62.16s-60 28.56-60 65.52c0 38.88 25.2 65.52 61.2 65.52z",
"m-1.44-106.8c18.24 0 26.88 12 27.12 25.92h-57.84c4.32-17.04 15.84-25.92 30.72-25.92z",
"M823.98 240h28.8v-73.92c0-18 13.2-27.6 26.16-27.6 15.84 0 22.08 11.28 22.08 26.88V240h28.8v-83.04c0-27.12-15.84-45.36-42.24-45.36-16.32 0-27.6 7.44-34.8 15.84V114h-28.8z",
"M1014.17 67.68 948.89 240h30.48l14.64-39.36h74.4l14.88 39.36h30.96l-65.28-172.32z",
"m16.8 34.08 27.36 72h-54.24z",
"M1163.69 68.18h-30.72V240.5h30.72z",
"M297.06 130.97a79.712 79.712 0 0 0-6.85-65.48c-17.46-30.4-52.56-46.04-86.84-38.68A79.747 79.747 0 0 0 143.24 0C108.2-.08 77.11 22.48 66.33 55.82a79.754 79.754 0 0 0-53.31 38.67c-17.59 30.32-13.58 68.54 9.92 94.54a79.712 79.712 0 0 0 6.85 65.48c17.46 30.4 52.56 46.04 86.84 38.68a79.687 79.687 0 0 0 60.13 26.8c35.06.09 66.16-22.49 76.94-55.86a79.754 79.754 0 0 0 53.31-38.67c17.57-30.32 13.55-68.51-9.94-94.51z",
"M176.78 299.08a59.77 59.77 0 0 1-38.39-13.88c.49-.26 1.34-.73 1.89-1.07l63.72-36.8a10.36 10.36 0 0 0 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97z",
"M47.94 244.05a59.71 59.71 0 0 1-7.15-40.18c.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83L129.87 266c-28.69 16.52-65.33 6.7-81.92-21.95z",
"M31.17 104.96c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91L118.44 224c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89z",
"m221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94a59.94 59.94 0 0 1-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06z",
"m26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8a10.375 10.375 0 0 0-10.47 0l-77.79 44.92V92c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22a59.95 59.95 0 0 1 7.15 40.1z",
"m-168.51 55.43-26.94-15.55a.943.943 0 0 1-.52-.74V80.86c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07L116 72.67a10.344 10.344 0 0 0-5.24 9.06l-.04 89.79z",
"M125.35 140 160 119.99l34.65 20V180L160 200l-34.65-20z",
].join(" ")
const OpenaiWordmarkLight = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 1180 320">
<path d={OPENAI_WORDMARK_LIGHT_PATH} />
</svg>
)
export { OpenaiWordmarkLight }
@@ -0,0 +1,32 @@
import type { SVGProps } from "react"
const SlackWordmark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} fill="currentColor" viewBox="0 0 2500 632.6">
<path
fillRule="evenodd"
d="m799.8 498.1 31.2-72.5c33.7 25.2 78.6 38.3 122.9 38.3 32.7 0 53.4-12.6 53.4-31.7-.5-53.4-195.9-11.6-197.4-145.5-.5-68 59.9-120.4 145.5-120.4 50.9 0 101.7 12.6 138 41.3l-29.2 74c-33.2-21.2-74.5-36.3-113.8-36.3-26.7 0-44.3 12.6-44.3 28.7.5 52.4 197.4 23.7 199.4 151.6 0 69.5-58.9 118.4-143.5 118.4-62-.1-118.9-14.7-162.2-45.9m1198.1-98.7c-15.6 27.2-44.8 45.8-78.6 45.8-49.9 0-90.1-40.3-90.1-90.1s40.3-90.1 90.1-90.1c33.7 0 63 18.6 78.6 45.8L2084 263c-32.2-57.4-94.2-96.7-164.7-96.7-104.3 0-188.9 84.6-188.9 188.9s84.6 188.9 188.9 188.9c71 0 132.5-38.8 164.7-96.7zM1148.8 9.6h107.8v527.3h-107.8zm977.5 0v527.3h107.8V378.7L2362 536.9h138L2337.3 349l150.6-175.3h-132L2234 319.2V9.6z"
clipRule="evenodd"
/>
<path d="M1576.9 400.4c-15.6 25.7-47.8 44.8-84.1 44.8-49.9 0-90.1-40.3-90.1-90.1s40.3-90.1 90.1-90.1c36.3 0 68.5 20.1 84.1 46.3zm0-226.6v42.8c-17.6-29.7-61.4-50.4-107.3-50.4-94.7 0-169.2 83.6-169.2 188.4S1374.9 544 1469.6 544c45.8 0 89.6-20.6 107.3-50.4v42.8h107.8V173.8z" />
<g fillRule="evenodd" clipRule="evenodd">
<path
fill="#e01e5a"
d="M133.5 399.9c0 36.8-29.7 66.5-66.5 66.5S.5 436.6.5 399.9s29.7-66.5 66.5-66.5h66.5zm33.2 0c0-36.8 29.7-66.5 66.5-66.5s66.5 29.7 66.5 66.5v166.2c0 36.8-29.7 66.5-66.5 66.5s-66.5-29.7-66.5-66.5z"
/>
<path
fill="#36c5f0"
d="M233.2 133c-36.8 0-66.5-29.7-66.5-66.5S196.4 0 233.2 0s66.5 29.7 66.5 66.5V133zm0 33.7c36.8 0 66.5 29.7 66.5 66.5s-29.7 66.5-66.5 66.5H66.5C29.7 299.7 0 269.9 0 233.2s29.7-66.5 66.5-66.5z"
/>
<path
fill="#2eb67d"
d="M499.6 233.2c0-36.8 29.7-66.5 66.5-66.5s66.5 29.7 66.5 66.5-29.7 66.5-66.5 66.5h-66.5zm-33.2 0c0 36.8-29.7 66.5-66.5 66.5s-66.5-29.7-66.5-66.5V66.5c0-36.8 29.7-66.5 66.5-66.5s66.5 29.7 66.5 66.5z"
/>
<path
fill="#ecb22e"
d="M399.9 499.6c36.8 0 66.5 29.7 66.5 66.5s-29.7 66.5-66.5 66.5-66.5-29.7-66.5-66.5v-66.5zm0-33.2c-36.8 0-66.5-29.7-66.5-66.5s29.7-66.5 66.5-66.5h166.7c36.8 0 66.5 29.7 66.5 66.5s-29.7 66.5-66.5 66.5z"
/>
</g>
</svg>
)
export { SlackWordmark }
@@ -0,0 +1,12 @@
import type { SVGProps } from "react";
const StripeWordmark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 512 214">
<path
fill="#635bff"
d="M512 110.08c0-36.409-17.636-65.138-51.342-65.138-33.85 0-54.33 28.73-54.33 64.854 0 42.808 24.179 64.426 58.88 64.426 16.925 0 29.725-3.84 39.396-9.244v-28.445c-9.67 4.836-20.764 7.823-34.844 7.823-13.796 0-26.027-4.836-27.591-21.618h69.547c0-1.85.284-9.245.284-12.658m-70.258-13.511c0-16.071 9.814-22.756 18.774-22.756 8.675 0 17.92 6.685 17.92 22.756zm-90.31-51.627c-13.939 0-22.899 6.542-27.876 11.094l-1.85-8.818h-31.288v165.83l35.555-7.537.143-40.249c5.12 3.698 12.657 8.96 25.173 8.96 25.458 0 48.64-20.48 48.64-65.564-.142-41.245-23.609-63.716-48.498-63.716m-8.534 97.991c-8.391 0-13.37-2.986-16.782-6.684l-.143-52.765c3.698-4.124 8.818-6.968 16.925-6.968 12.942 0 21.902 14.506 21.902 33.137 0 19.058-8.818 33.28-21.902 33.28M241.493 36.551l35.698-7.68V0l-35.698 7.538zm0 10.809h35.698v124.444h-35.698zm-38.257 10.524L200.96 47.36h-30.72v124.444h35.556V87.467c8.39-10.951 22.613-8.96 27.022-7.396V47.36c-4.551-1.707-21.191-4.836-29.582 10.524m-71.112-41.386-34.702 7.395-.142 113.92c0 21.05 15.787 36.551 36.836 36.551 11.662 0 20.195-2.133 24.888-4.693V140.8c-4.55 1.849-27.022 8.391-27.022-12.658V77.653h27.022V47.36h-27.022zM35.982 83.484c0-5.546 4.551-7.68 12.09-7.68 10.808 0 24.461 3.272 35.27 9.103V51.484c-11.804-4.693-23.466-6.542-35.27-6.542C19.2 44.942 0 60.018 0 85.192c0 39.252 54.044 32.995 54.044 49.92 0 6.541-5.688 8.675-13.653 8.675-11.804 0-26.88-4.836-38.827-11.378v33.849c13.227 5.689 26.596 8.106 38.827 8.106 29.582 0 49.92-14.648 49.92-40.106-.142-42.382-54.329-34.845-54.329-50.774"
/>
</svg>
);
export { StripeWordmark };
@@ -0,0 +1,77 @@
import type { SVGProps } from "react"
const SupabaseWordmarkDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 581 113" fill="none">
<path
d="M151.397 66.7608C151.996 72.3621 157.091 81.9642 171.877 81.9642C184.764 81.9642 190.959 73.7624 190.959 65.7607C190.959 58.559 186.063 52.6577 176.373 50.6571L169.379 49.1569C166.682 48.6568 164.884 47.1565 164.884 44.7559C164.884 41.9552 167.681 39.8549 171.178 39.8549C176.772 39.8549 178.87 43.5556 179.27 46.4564L190.359 43.9558C189.76 38.6546 185.064 29.7527 171.078 29.7527C160.488 29.7527 152.696 37.0543 152.696 45.8561C152.696 52.7576 156.991 58.4591 166.482 60.5594L172.976 62.0598C176.772 62.8599 178.271 64.6605 178.271 66.8609C178.271 69.4615 176.173 71.762 171.777 71.762C165.983 71.762 163.085 68.1611 162.786 64.2602L151.397 66.7608Z"
fill="white"
/>
<path
d="M233.421 80.4639H246.109C245.909 78.7635 245.609 75.3628 245.609 71.5618V31.2529H232.321V59.8592C232.321 65.5606 228.925 69.5614 223.031 69.5614C216.837 69.5614 214.039 65.1604 214.039 59.6592V31.2529H200.752V62.3599C200.752 73.0622 207.545 81.7642 219.434 81.7642C224.628 81.7642 230.325 79.7638 233.022 75.1627C233.022 77.1631 233.221 79.4636 233.421 80.4639Z"
fill="white"
/>
<path
d="M273.076 99.4682V75.663C275.473 78.9636 280.469 81.6644 287.263 81.6644C301.149 81.6644 310.439 70.6617 310.439 55.7584C310.439 41.1553 302.148 30.1528 287.762 30.1528C280.37 30.1528 274.875 33.4534 272.677 37.2544V31.253H259.79V99.4682H273.076ZM297.352 55.8585C297.352 64.6606 291.958 69.7616 285.164 69.7616C278.372 69.7616 272.877 64.5605 272.877 55.8585C272.877 47.1566 278.372 42.0554 285.164 42.0554C291.958 42.0554 297.352 47.1566 297.352 55.8585Z"
fill="white"
/>
<path
d="M317.964 67.0609C317.964 74.7627 324.357 81.8643 334.848 81.8643C342.139 81.8643 346.835 78.4634 349.332 74.5625C349.332 76.463 349.532 79.1635 349.832 80.4639H362.02C361.72 78.7635 361.422 75.2627 361.422 72.6622V48.4567C361.422 38.5545 355.627 29.7527 340.043 29.7527C326.855 29.7527 319.761 38.2544 318.963 45.9562L330.751 48.4567C331.151 44.1558 334.348 40.455 340.141 40.455C345.737 40.455 348.434 43.3556 348.434 46.8564C348.434 48.5568 347.536 49.9572 344.738 50.3572L332.65 52.1576C324.458 53.3579 317.964 58.2589 317.964 67.0609ZM337.644 71.962C333.349 71.962 331.25 69.1614 331.25 66.2608C331.25 62.4599 333.947 60.5594 337.345 60.0594L348.434 58.359V60.5594C348.434 69.2615 343.239 71.962 337.644 71.962Z"
fill="white"
/>
<path
d="M387.703 80.4641V74.4627C390.299 78.6637 395.494 81.6644 402.288 81.6644C416.276 81.6644 425.467 70.5618 425.467 55.6585C425.467 41.0552 417.174 29.9528 402.788 29.9528C395.494 29.9528 390.1 33.1535 387.902 36.6541V8.04785H374.815V80.4641H387.703ZM412.178 55.7584C412.178 64.7605 406.784 69.7616 399.99 69.7616C393.297 69.7616 387.703 64.6606 387.703 55.7584C387.703 46.7564 393.297 41.8554 399.99 41.8554C406.784 41.8554 412.178 46.7564 412.178 55.7584Z"
fill="white"
/>
<path
d="M432.99 67.0609C432.99 74.7627 439.383 81.8643 449.873 81.8643C457.165 81.8643 461.862 78.4634 464.358 74.5625C464.358 76.463 464.559 79.1635 464.858 80.4639H477.046C476.748 78.7635 476.448 75.2627 476.448 72.6622V48.4567C476.448 38.5545 470.653 29.7527 455.068 29.7527C441.881 29.7527 434.788 38.2544 433.989 45.9562L445.776 48.4567C446.177 44.1558 449.374 40.455 455.167 40.455C460.763 40.455 463.46 43.3556 463.46 46.8564C463.46 48.5568 462.561 49.9572 459.763 50.3572L447.676 52.1576C439.484 53.3579 432.99 58.2589 432.99 67.0609ZM452.671 71.962C448.375 71.962 446.276 69.1614 446.276 66.2608C446.276 62.4599 448.973 60.5594 452.371 60.0594L463.46 58.359V60.5594C463.46 69.2615 458.265 71.962 452.671 71.962Z"
fill="white"
/>
<path
d="M485.645 66.7608C486.243 72.3621 491.339 81.9642 506.124 81.9642C519.012 81.9642 525.205 73.7624 525.205 65.7607C525.205 58.559 520.311 52.6577 510.62 50.6571L503.626 49.1569C500.929 48.6568 499.132 47.1565 499.132 44.7559C499.132 41.9552 501.928 39.8549 505.425 39.8549C511.021 39.8549 513.118 43.5556 513.519 46.4564L524.607 43.9558C524.007 38.6546 519.312 29.7527 505.326 29.7527C494.735 29.7527 486.944 37.0543 486.944 45.8561C486.944 52.7576 491.238 58.4591 500.73 60.5594L507.224 62.0598C511.021 62.8599 512.519 64.6605 512.519 66.8609C512.519 69.4615 510.421 71.762 506.025 71.762C500.23 71.762 497.334 68.1611 497.034 64.2602L485.645 66.7608Z"
fill="white"
/>
<path
d="M545.385 50.2571C545.685 45.7562 549.482 40.5549 556.375 40.5549C563.967 40.5549 567.165 45.3561 567.365 50.2571H545.385ZM568.664 63.0601C567.065 67.4609 563.668 70.5617 557.474 70.5617C550.88 70.5617 545.385 65.8606 545.087 59.3593H580.252C580.252 59.159 580.451 57.1587 580.451 55.2582C580.451 39.4547 571.361 29.7527 556.175 29.7527C543.588 29.7527 531.998 39.9548 531.998 55.6584C531.998 72.262 543.886 81.9642 557.374 81.9642C569.462 81.9642 577.255 74.8626 579.753 66.3607L568.664 63.0601Z"
fill="white"
/>
<path
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
fill="url(#paint0_linear)"
/>
<path
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
fill="url(#paint1_linear)"
fillOpacity="0.2"
/>
<path
d="M45.317 2.07103C48.1765 -1.53037 53.9745 0.442937 54.0434 5.041L54.4849 72.2922H9.83113C1.64038 72.2922 -2.92775 62.8321 2.1655 56.4175L45.317 2.07103Z"
fill="#3ECF8E"
/>
<defs>
<linearGradient
id="paint0_linear"
x1="53.9738"
y1="54.974"
x2="94.1635"
y2="71.8295"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#249361" />
<stop offset="1" stopColor="#3ECF8E" />
</linearGradient>
<linearGradient
id="paint1_linear"
x1="36.1558"
y1="30.578"
x2="54.4844"
y2="65.0806"
gradientUnits="userSpaceOnUse"
>
<stop />
<stop offset="1" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
)
export { SupabaseWordmarkDark }
@@ -0,0 +1,77 @@
import type { SVGProps } from "react"
const SupabaseWordmarkLight = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 581 113" fill="none">
<path
d="M151.397 66.7608C151.996 72.3621 157.091 81.9642 171.877 81.9642C184.764 81.9642 190.959 73.7624 190.959 65.7607C190.959 58.559 186.063 52.6577 176.373 50.6571L169.379 49.1569C166.682 48.6568 164.884 47.1565 164.884 44.7559C164.884 41.9552 167.681 39.8549 171.178 39.8549C176.772 39.8549 178.87 43.5556 179.27 46.4564L190.359 43.9558C189.76 38.6546 185.064 29.7527 171.078 29.7527C160.488 29.7527 152.696 37.0543 152.696 45.8561C152.696 52.7576 156.991 58.4591 166.482 60.5594L172.976 62.0598C176.772 62.8599 178.271 64.6605 178.271 66.8609C178.271 69.4615 176.173 71.762 171.777 71.762C165.983 71.762 163.085 68.1611 162.786 64.2602L151.397 66.7608Z"
fill="#1F1F1F"
/>
<path
d="M233.421 80.4639H246.109C245.909 78.7635 245.609 75.3628 245.609 71.5618V31.2529H232.321V59.8592C232.321 65.5606 228.925 69.5614 223.031 69.5614C216.837 69.5614 214.039 65.1604 214.039 59.6592V31.2529H200.752V62.3599C200.752 73.0622 207.545 81.7642 219.434 81.7642C224.628 81.7642 230.325 79.7638 233.022 75.1627C233.022 77.1631 233.221 79.4636 233.421 80.4639Z"
fill="#1F1F1F"
/>
<path
d="M273.076 99.4682V75.663C275.473 78.9636 280.469 81.6644 287.263 81.6644C301.149 81.6644 310.439 70.6617 310.439 55.7584C310.439 41.1553 302.148 30.1528 287.762 30.1528C280.37 30.1528 274.875 33.4534 272.677 37.2544V31.253H259.79V99.4682H273.076ZM297.352 55.8585C297.352 64.6606 291.958 69.7616 285.164 69.7616C278.372 69.7616 272.877 64.5605 272.877 55.8585C272.877 47.1566 278.372 42.0554 285.164 42.0554C291.958 42.0554 297.352 47.1566 297.352 55.8585Z"
fill="#1F1F1F"
/>
<path
d="M317.964 67.0609C317.964 74.7627 324.357 81.8643 334.848 81.8643C342.139 81.8643 346.835 78.4634 349.332 74.5625C349.332 76.463 349.532 79.1635 349.832 80.4639H362.02C361.72 78.7635 361.422 75.2627 361.422 72.6622V48.4567C361.422 38.5545 355.627 29.7527 340.043 29.7527C326.855 29.7527 319.761 38.2544 318.963 45.9562L330.751 48.4567C331.151 44.1558 334.348 40.455 340.141 40.455C345.737 40.455 348.434 43.3556 348.434 46.8564C348.434 48.5568 347.536 49.9572 344.738 50.3572L332.65 52.1576C324.458 53.3579 317.964 58.2589 317.964 67.0609ZM337.644 71.962C333.349 71.962 331.25 69.1614 331.25 66.2608C331.25 62.4599 333.947 60.5594 337.345 60.0594L348.434 58.359V60.5594C348.434 69.2615 343.239 71.962 337.644 71.962Z"
fill="#1F1F1F"
/>
<path
d="M387.703 80.4641V74.4627C390.299 78.6637 395.494 81.6644 402.288 81.6644C416.276 81.6644 425.467 70.5618 425.467 55.6585C425.467 41.0552 417.174 29.9528 402.788 29.9528C395.494 29.9528 390.1 33.1535 387.902 36.6541V8.04785H374.815V80.4641H387.703ZM412.178 55.7584C412.178 64.7605 406.784 69.7616 399.99 69.7616C393.297 69.7616 387.703 64.6606 387.703 55.7584C387.703 46.7564 393.297 41.8554 399.99 41.8554C406.784 41.8554 412.178 46.7564 412.178 55.7584Z"
fill="#1F1F1F"
/>
<path
d="M432.99 67.0609C432.99 74.7627 439.383 81.8643 449.873 81.8643C457.165 81.8643 461.862 78.4634 464.358 74.5625C464.358 76.463 464.559 79.1635 464.858 80.4639H477.046C476.748 78.7635 476.448 75.2627 476.448 72.6622V48.4567C476.448 38.5545 470.653 29.7527 455.068 29.7527C441.881 29.7527 434.788 38.2544 433.989 45.9562L445.776 48.4567C446.177 44.1558 449.374 40.455 455.167 40.455C460.763 40.455 463.46 43.3556 463.46 46.8564C463.46 48.5568 462.561 49.9572 459.763 50.3572L447.676 52.1576C439.484 53.3579 432.99 58.2589 432.99 67.0609ZM452.671 71.962C448.375 71.962 446.276 69.1614 446.276 66.2608C446.276 62.4599 448.973 60.5594 452.371 60.0594L463.46 58.359V60.5594C463.46 69.2615 458.265 71.962 452.671 71.962Z"
fill="#1F1F1F"
/>
<path
d="M485.645 66.7608C486.243 72.3621 491.339 81.9642 506.124 81.9642C519.012 81.9642 525.205 73.7624 525.205 65.7607C525.205 58.559 520.311 52.6577 510.62 50.6571L503.626 49.1569C500.929 48.6568 499.132 47.1565 499.132 44.7559C499.132 41.9552 501.928 39.8549 505.425 39.8549C511.021 39.8549 513.118 43.5556 513.519 46.4564L524.607 43.9558C524.007 38.6546 519.312 29.7527 505.326 29.7527C494.735 29.7527 486.944 37.0543 486.944 45.8561C486.944 52.7576 491.238 58.4591 500.73 60.5594L507.224 62.0598C511.021 62.8599 512.519 64.6605 512.519 66.8609C512.519 69.4615 510.421 71.762 506.025 71.762C500.23 71.762 497.334 68.1611 497.034 64.2602L485.645 66.7608Z"
fill="#1F1F1F"
/>
<path
d="M545.385 50.2571C545.685 45.7562 549.482 40.5549 556.375 40.5549C563.967 40.5549 567.165 45.3561 567.365 50.2571H545.385ZM568.664 63.0601C567.065 67.4609 563.668 70.5617 557.474 70.5617C550.88 70.5617 545.385 65.8606 545.087 59.3593H580.252C580.252 59.159 580.451 57.1587 580.451 55.2582C580.451 39.4547 571.361 29.7527 556.175 29.7527C543.588 29.7527 531.998 39.9548 531.998 55.6584C531.998 72.262 543.886 81.9642 557.374 81.9642C569.462 81.9642 577.255 74.8626 579.753 66.3607L568.664 63.0601Z"
fill="#1F1F1F"
/>
<path
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
fill="url(#paint0_linear)"
/>
<path
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
fill="url(#paint1_linear)"
fillOpacity="0.2"
/>
<path
d="M45.317 2.07103C48.1765 -1.53037 53.9745 0.442937 54.0434 5.041L54.4849 72.2922H9.83113C1.64038 72.2922 -2.92775 62.8321 2.1655 56.4175L45.317 2.07103Z"
fill="#3ECF8E"
/>
<defs>
<linearGradient
id="paint0_linear"
x1="53.9738"
y1="54.974"
x2="94.1635"
y2="71.8295"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#249361" />
<stop offset="1" stopColor="#3ECF8E" />
</linearGradient>
<linearGradient
id="paint1_linear"
x1="36.1558"
y1="30.578"
x2="54.4844"
y2="65.0806"
gradientUnits="userSpaceOnUse"
>
<stop />
<stop offset="1" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
)
export { SupabaseWordmarkLight }
+64
View File
@@ -0,0 +1,64 @@
import { clearToken, getToken } from '@/lib/auth'
export class ApiError extends Error {
status: number
code: string
constructor(status: number, code: string, message: string) {
super(message)
this.status = status
this.code = code
}
}
async function request<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
}
const token = getToken()
if (token) headers.Authorization = `Bearer ${token}`
if (body !== undefined) headers['Content-Type'] = 'application/json'
const res = await fetch(path, {
method,
headers,
credentials: 'include',
body: body === undefined ? undefined : JSON.stringify(body),
})
if (res.status === 204) return undefined as T
const text = await res.text()
let data: unknown = null
if (text) {
try {
data = JSON.parse(text)
} catch {
data = { error: { code: 'PARSE_ERROR', message: text } }
}
}
if (!res.ok) {
if (res.status === 401) clearToken()
const err = data as { error?: { code?: string; message?: string } } | null
throw new ApiError(
res.status,
err?.error?.code ?? 'ERROR',
err?.error?.message ?? res.statusText,
)
}
return data as T
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
}
+13
View File
@@ -0,0 +1,13 @@
const TOKEN_KEY = 'authportal_token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
+36
View File
@@ -0,0 +1,36 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { ThemeProvider } from 'next-themes'
import { Toaster } from 'sonner'
import { routeTree } from './routeTree.gen'
import '@authportal/ui/globals.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
},
})
const router = createRouter({
routeTree,
context: { queryClient },
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster richColors position="top-right" />
</QueryClientProvider>
</ThemeProvider>
</StrictMode>,
)
+47
View File
@@ -0,0 +1,47 @@
import { queryOptions } from '@tanstack/react-query'
import type {
AdminUser,
CatalogResponse,
LoginResponse,
MeResponse,
} from '@authportal/shared'
import { api } from '@/lib/api-client'
export const meQueryKey = ['me'] as const
export const catalogQueryKey = ['catalog'] as const
export const usersQueryKey = ['admin', 'users'] as const
export const meQueryOptions = queryOptions({
queryKey: meQueryKey,
queryFn: () => api.get<MeResponse>('/api/v1/auth/me'),
retry: false,
})
export const catalogQueryOptions = queryOptions({
queryKey: catalogQueryKey,
queryFn: () => api.get<CatalogResponse>('/api/v1/catalog'),
})
export const usersQueryOptions = queryOptions({
queryKey: usersQueryKey,
queryFn: () => api.get<AdminUser[]>('/api/v1/admin/users'),
})
export function userQueryOptions(id: string) {
return queryOptions({
queryKey: [...usersQueryKey, id] as const,
queryFn: () => api.get<AdminUser>(`/api/v1/admin/users/${id}`),
})
}
export function login(email: string, password: string, returnTo?: string) {
return api.post<LoginResponse>('/api/v1/auth/login', {
email,
password,
...(returnTo ? { return_to: returnTo } : {}),
})
}
export function logout() {
return api.post<{ ok: boolean }>('/api/v1/auth/logout')
}
+170
View File
@@ -0,0 +1,170 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as AuthAdminRouteImport } from './routes/_auth.admin'
import { Route as AuthAppsRouteImport } from './routes/_auth.apps'
import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const AuthAdminRoute = AuthAdminRouteImport.update({
id: '/admin',
path: '/admin',
getParentRoute: () => AuthRoute,
} as any)
const AuthAppsRoute = AuthAppsRouteImport.update({
id: '/apps',
path: '/apps',
getParentRoute: () => AuthRoute,
} as any)
const AuthAdminIndexRoute = AuthAdminIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthAdminRoute,
} as any)
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
id: '/users/$userId',
path: '/users/$userId',
getParentRoute: () => AuthAdminRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/admin': typeof AuthAdminRouteWithChildren
'/apps': typeof AuthAppsRoute
'/admin/': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/apps': typeof AuthAppsRoute
'/admin': typeof AuthAdminIndexRoute
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/_auth/admin': typeof AuthAdminRouteWithChildren
'/_auth/apps': typeof AuthAppsRoute
'/_auth/admin/': typeof AuthAdminIndexRoute
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/admin' | '/apps' | '/admin/' | '/admin/users/$userId'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/apps' | '/admin' | '/admin/users/$userId'
id:
| '__root__'
| '/'
| '/_auth'
| '/_auth/admin'
| '/_auth/apps'
| '/_auth/admin/'
| '/_auth/admin/users/$userId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/admin': {
id: '/_auth/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof AuthAdminRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/apps': {
id: '/_auth/apps'
path: '/apps'
fullPath: '/apps'
preLoaderRoute: typeof AuthAppsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/admin/': {
id: '/_auth/admin/'
path: '/'
fullPath: '/admin/'
preLoaderRoute: typeof AuthAdminIndexRouteImport
parentRoute: typeof AuthAdminRoute
}
'/_auth/admin/users/$userId': {
id: '/_auth/admin/users/$userId'
path: '/users/$userId'
fullPath: '/admin/users/$userId'
preLoaderRoute: typeof AuthAdminUsersUserIdRouteImport
parentRoute: typeof AuthAdminRoute
}
}
}
interface AuthAdminRouteChildren {
AuthAdminIndexRoute: typeof AuthAdminIndexRoute
AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute
}
const AuthAdminRouteChildren: AuthAdminRouteChildren = {
AuthAdminIndexRoute: AuthAdminIndexRoute,
AuthAdminUsersUserIdRoute: AuthAdminUsersUserIdRoute,
}
const AuthAdminRouteWithChildren = AuthAdminRoute._addFileChildren(
AuthAdminRouteChildren,
)
interface AuthRouteChildren {
AuthAdminRoute: typeof AuthAdminRouteWithChildren
AuthAppsRoute: typeof AuthAppsRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAdminRoute: AuthAdminRouteWithChildren,
AuthAppsRoute: AuthAppsRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
+14
View File
@@ -0,0 +1,14 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: RootLayout,
})
function RootLayout() {
return (
<div className="min-h-svh bg-background text-foreground">
<Outlet />
</div>
)
}
+368
View File
@@ -0,0 +1,368 @@
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
} from '@tanstack/react-table'
import type { AdminUser, CreateUserRequest } from '@authportal/shared'
import { PageShell } from '@/components/page-shell'
import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { Button } from '@authportal/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import { Checkbox } from '@authportal/ui/components/checkbox'
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@authportal/ui/components/sheet'
import {
Tabs,
TabsList,
TabsTrigger,
} from '@authportal/ui/components/tabs'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { api, ApiError } from '@/lib/api-client'
import { usersQueryKey, usersQueryOptions } from '@/queries/auth'
export const Route = createFileRoute('/_auth/admin/')({
component: AdminUsersPage,
})
type TabId = 'all' | 'admins' | 'disabled'
function AdminUsersPage() {
const queryClient = useQueryClient()
const { data: users = [], isLoading, error, refetch } = useQuery(usersQueryOptions)
const [tab, setTab] = useState<TabId>('all')
const [search, setSearch] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [accessUserId, setAccessUserId] = useState<string | null>(null)
const filtered = useMemo(() => {
let rows = users
if (tab === 'admins') rows = rows.filter((u) => u.is_admin)
if (tab === 'disabled') rows = rows.filter((u) => u.disabled)
const q = search.trim().toLowerCase()
if (q) {
rows = rows.filter(
(u) =>
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q),
)
}
return rows
}, [users, tab, search])
const counts = useMemo(
() => ({
all: users.length,
admins: users.filter((u) => u.is_admin).length,
disabled: users.filter((u) => u.disabled).length,
}),
[users],
)
const columns = useMemo<ColumnDef<AdminUser, unknown>[]>(
() => [
{
accessorKey: 'name',
header: 'Имя',
cell: ({ row }) => (
<div className="flex flex-col gap-0.5">
<span className="font-medium">{row.original.name}</span>
<span className="text-muted-foreground text-xs">
{row.original.email}
</span>
</div>
),
},
{
id: 'role',
header: 'Роль',
cell: ({ row }) =>
row.original.is_admin ? (
<Badge variant="warning-light" size="sm">
Админ
</Badge>
) : (
<Badge variant="secondary" size="sm">
Пользователь
</Badge>
),
},
{
id: 'apps',
header: 'Apps',
cell: ({ row }) => (
<span className="tabular-nums">{row.original.apps.length}</span>
),
},
{
id: 'status',
header: 'Статус',
cell: ({ row }) =>
row.original.disabled ? (
<Badge variant="destructive-light" size="sm">
Отключён
</Badge>
) : (
<Badge variant="success-light" size="sm">
Активен
</Badge>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Button
size="sm"
variant="outline"
onClick={() => setAccessUserId(row.original.id)}
>
Права
</Button>
),
},
],
[],
)
const table = useReactTable({
data: filtered,
columns,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 10 } },
})
const createMutation = useMutation({
mutationFn: (body: CreateUserRequest) =>
api.post<AdminUser>('/api/v1/admin/users', body),
onSuccess: async (user) => {
await queryClient.invalidateQueries({ queryKey: usersQueryKey })
setCreateOpen(false)
setAccessUserId(user.id)
},
})
return (
<PageShell>
<Frame dense className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-4">
<div className="flex flex-col gap-px">
<FrameTitle>Пользователи</FrameTitle>
<FrameDescription>
Управление доступом к приложениям и разделам
</FrameDescription>
</div>
<Button onClick={() => setCreateOpen(true)}>Создать</Button>
</FrameHeader>
<FramePanel className="flex flex-col gap-4 p-0">
<div className="flex flex-col gap-3 border-b px-4 pt-3">
<Tabs
value={tab}
onValueChange={(v) => setTab(v as TabId)}
>
<TabsList variant="line" className="gap-5">
{(
[
['all', 'Все', counts.all],
['admins', 'Админы', counts.admins],
['disabled', 'Отключённые', counts.disabled],
] as const
).map(([id, label, count]) => (
<TabsTrigger
key={id}
value={id}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3"
>
<span>{label}</span>
<span className="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{count}
</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="pb-3">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по имени или email…"
className="bg-background max-w-sm"
/>
</div>
</div>
{error ? (
<div className="flex flex-col gap-2 p-4">
<p className="text-destructive text-sm">
{error instanceof ApiError ? error.message : 'Ошибка загрузки'}
</p>
<Button variant="outline" size="sm" className="w-fit" onClick={() => refetch()}>
Повторить
</Button>
</div>
) : isLoading ? (
<div className="flex flex-col gap-2 p-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-start gap-3 p-6 text-sm">
<p>Нет пользователей по текущему фильтру.</p>
<Button variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
Создать пользователя
</Button>
</div>
) : (
<DataGrid
table={table}
recordCount={filtered.length}
tableLayout={{ dense: true, width: 'auto' }}
>
<div className="relative">
<DataGridTable />
</div>
</DataGrid>
)}
</FramePanel>
{!isLoading && filtered.length > 0 ? (
<FrameFooter className="border-t">
<DataGrid table={table} recordCount={filtered.length}>
<DataGridPagination />
</DataGrid>
</FrameFooter>
) : null}
</Frame>
<CreateUserSheet
open={createOpen}
onOpenChange={setCreateOpen}
pending={createMutation.isPending}
error={
createMutation.error instanceof ApiError
? createMutation.error.message
: createMutation.error
? 'Не удалось создать'
: null
}
onSubmit={(values) => createMutation.mutate(values)}
/>
<UserAccessSheet
userId={accessUserId}
open={Boolean(accessUserId)}
onOpenChange={(next) => {
if (!next) setAccessUserId(null)
}}
/>
</PageShell>
)
}
function CreateUserSheet({
open,
onOpenChange,
pending,
error,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
pending: boolean
error: string | null
onSubmit: (values: CreateUserRequest) => void
}) {
const [isAdmin, setIsAdmin] = useState(false)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
<SheetHeader>
<SheetTitle>Новый пользователь</SheetTitle>
</SheetHeader>
<form
className="flex flex-1 flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
onSubmit({
email: String(fd.get('email') ?? ''),
name: String(fd.get('name') ?? ''),
password: String(fd.get('password') ?? ''),
is_admin: isAdmin,
apps: [],
permissions: [],
})
}}
>
<FieldGroup className="gap-3">
<Field>
<FieldLabel htmlFor="create-name">Имя</FieldLabel>
<Input id="create-name" name="name" required />
</Field>
<Field>
<FieldLabel htmlFor="create-email">Email</FieldLabel>
<Input id="create-email" name="email" type="email" required />
</Field>
<Field>
<FieldLabel htmlFor="create-password">Пароль</FieldLabel>
<Input
id="create-password"
name="password"
type="password"
minLength={6}
required
/>
</Field>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={isAdmin}
onCheckedChange={(v) => setIsAdmin(v === true)}
/>
Администратор портала
</label>
</FieldGroup>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
<SheetFooter className="mt-auto">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Отмена
</Button>
<Button type="submit" disabled={pending}>
{pending ? 'Создание…' : 'Создать'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { Outlet, createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/admin')({
component: AdminLayout,
})
function AdminLayout() {
return <Outlet />
}
@@ -0,0 +1,8 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
/** Legacy URL → список; правка прав открывается Sheet’ом на /admin. */
export const Route = createFileRoute('/_auth/admin/users/$userId')({
beforeLoad: () => {
throw redirect({ to: '/admin' })
},
})
+119
View File
@@ -0,0 +1,119 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { LayoutGridIcon } from 'lucide-react'
import { APPS, buildSsoRedirectUrl, type AppId } from '@authportal/shared'
import { PageShell } from '@/components/page-shell'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { getToken } from '@/lib/auth'
import { meQueryOptions } from '@/queries/auth'
export const Route = createFileRoute('/_auth/apps')({
component: AppsPage,
})
const APP_URL_OVERRIDES: Partial<Record<AppId, string | undefined>> = {
vps: import.meta.env.VITE_VPS_APP_URL,
cfdm: import.meta.env.VITE_CFDM_APP_URL,
bgp: import.meta.env.VITE_BGP_APP_URL,
}
function appLaunchUrl(appId: AppId, defaultUrl: string): string {
return APP_URL_OVERRIDES[appId] || defaultUrl
}
function openApp(appId: AppId, defaultUrl: string) {
const base = appLaunchUrl(appId, defaultUrl).replace(/\/$/, '')
const token = getToken()
if (!token) {
window.open(base, '_blank', 'noreferrer')
return
}
const callback = `${base}/auth/callback`
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString()
window.location.href = buildSsoRedirectUrl(callback, token, expiresAt)
}
function AppsPage() {
const { data: me, isLoading } = useQuery(meQueryOptions)
const allowed = new Set(me?.apps ?? [])
const apps = APPS.filter((app) => allowed.has(app.id))
return (
<PageShell>
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Приложения</h1>
<p className="text-muted-foreground text-sm">
Открыть приложение с текущей сессией (SSO handoff).
</p>
</div>
{me?.is_admin ? (
<Button
variant="outline"
size="sm"
render={<Link to="/admin" />}
nativeButton={false}
>
Админка
</Button>
) : null}
</div>
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-40 w-full rounded-xl" />
))}
</div>
) : apps.length === 0 ? (
<Frame dense className="w-full">
<FramePanel className="flex flex-col items-start gap-3 py-10">
<div className="bg-muted flex size-10 items-center justify-center rounded-lg">
<LayoutGridIcon className="text-muted-foreground size-5" />
</div>
<div className="flex flex-col gap-1">
<p className="font-medium">Нет доступных приложений</p>
<p className="text-muted-foreground text-sm">
Обратитесь к администратору, чтобы получить доступ.
</p>
</div>
</FramePanel>
</Frame>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{apps.map((app) => (
<Frame key={app.id} dense className="h-full">
<FrameHeader className="flex-row items-start justify-between gap-2">
<div className="flex flex-col gap-px">
<FrameTitle>{app.title}</FrameTitle>
<FrameDescription>{app.description}</FrameDescription>
</div>
<Badge variant="info-light" size="sm">
{app.id}
</Badge>
</FrameHeader>
<FrameFooter>
<Button
className="w-full"
onClick={() => openApp(app.id, app.url)}
>
Открыть
</Button>
</FrameFooter>
</Frame>
))}
</div>
)}
</PageShell>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
import { AppShell } from '@/components/layout/app-shell'
import { clearToken, getToken } from '@/lib/auth'
import { api } from '@/lib/api-client'
import type { MeResponse } from '@authportal/shared'
export const Route = createFileRoute('/_auth')({
beforeLoad: async ({ location }) => {
if (!getToken()) {
throw redirect({ to: '/' })
}
try {
const me = await api.get<MeResponse>('/api/v1/auth/me')
if (location.pathname.startsWith('/admin') && !me.is_admin) {
throw redirect({ to: '/apps' })
}
} catch (err) {
if (err && typeof err === 'object' && 'to' in err) throw err
clearToken()
throw redirect({ to: '/' })
}
},
component: AuthLayout,
})
function AuthLayout() {
return (
<AppShell>
<Outlet />
</AppShell>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { z } from 'zod'
import {
buildSsoRedirectUrl,
isReturnToAllowed,
type MeResponse,
} from '@authportal/shared'
import { getToken } from '@/lib/auth'
import { PortalLoginForm } from '@/components/portal-login-form'
import { api } from '@/lib/api-client'
const searchSchema = z.object({
return_to: z.string().url().optional(),
})
const RETURN_TO_ALLOWLIST =
import.meta.env.VITE_RETURN_TO_ALLOWLIST ??
'.shnt.top,localhost,http://localhost:5173'
export const Route = createFileRoute('/')({
validateSearch: (search) => searchSchema.parse(search),
beforeLoad: async ({ search }) => {
const token = getToken()
if (!token) return
try {
const me = await api.get<MeResponse>('/api/v1/auth/me')
if (
search.return_to &&
isReturnToAllowed(search.return_to, RETURN_TO_ALLOWLIST)
) {
const exp = new Date(Date.now() + 60 * 60 * 1000).toISOString()
window.location.href = buildSsoRedirectUrl(
search.return_to,
token,
exp,
)
return
}
throw redirect({ to: me.is_admin ? '/admin' : '/apps' })
} catch (err) {
if (err && typeof err === 'object' && 'to' in err) throw err
/* stay on login if token invalid */
}
},
component: LoginPage,
})
function LoginPage() {
return <PortalLoginForm />
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"],
"@authportal/ui/components/*": ["../../packages/ui/src/components/*"],
"@authportal/ui/hooks/*": ["../../packages/ui/src/hooks/*"],
"@authportal/ui/lib/*": ["../../packages/ui/src/lib/*"],
"@authportal/ui/globals.css": ["../../packages/ui/src/styles/globals.css"],
"@authportal/shared": ["../../packages/shared/src/index.ts"]
}
},
"include": ["src"],
"exclude": ["src/components/blocks/**"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

Some files were not shown because too many files have changed in this diff Show More