First Commit
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"mcpServers": {}
|
||||
}
|
||||
@@ -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/*`
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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: 'Только для администраторов' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)!)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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 }
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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>()
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' })
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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/**"]
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user