Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd7d9fd13c | ||
|
|
01d3a79e13 | ||
|
|
0f1f89776b | ||
|
|
a591d12c69 | ||
|
|
46dc2f714c |
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** 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 skill version `3bdbad788a`.** 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `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.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**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" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**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`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** 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 skill version `3bdbad788a`.** 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `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.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**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" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**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`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -6,18 +6,18 @@ alwaysApply: false
|
||||
|
||||
---
|
||||
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.
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 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 `42d70dcc3d`.** 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 skill version `3bdbad788a`.** 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)
|
||||
- **components** - the 20 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
|
||||
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [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/components.md](./rules/components.md) - the 20 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)
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** 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 skill version `3bdbad788a`.** 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `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.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**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" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**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`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
+3
-2
@@ -16,7 +16,7 @@ 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,private,http://localhost:5173,http://localhost:5174
|
||||
RETURN_TO_ALLOWLIST=.shnt.top,localhost,private,http://localhost:5173,http://localhost:5174,http://localhost:5176
|
||||
|
||||
# ReUI PRO (apps/web/components.json → @reui Authorization)
|
||||
# Ключ: https://reui.io/docs/license-setup — класть в apps/web/.env.local (gitignored)
|
||||
@@ -24,7 +24,8 @@ 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
|
||||
# VITE_CDN_APP_URL=http://localhost:5176
|
||||
# VITE_RETURN_TO_ALLOWLIST=.shnt.top,localhost,http://localhost:5173,http://localhost:5176
|
||||
|
||||
# Server
|
||||
SERVER_PORT=8080
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** 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 skill version `3bdbad788a`.** 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `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.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**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" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**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`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ pnpm --filter web dev # :5175
|
||||
|
||||
См. [`docs/integrate-evobgp.md`](docs/integrate-evobgp.md) — EvoBGP (`bgp:*`).
|
||||
См. [`docs/integrate-evofirewall.md`](docs/integrate-evofirewall.md) — EvoFirewall (`fw:*`).
|
||||
См. [`docs/integrate-cdnmanager.md`](docs/integrate-cdnmanager.md) — CDN Manager (`cdn:*`, порт Vite `5176`).
|
||||
См. [`docs/integrate-mikrotikmanager.md`](docs/integrate-mikrotikmanager.md) — MikrotikManager (`mm:*`).
|
||||
См. [`docs/integrate-technitium.md`](docs/integrate-technitium.md) — Technitium DNS (`dns:*`, OIDC IdP).
|
||||
|
||||
Корень:
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"fastify": "^5.4.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"jose": "^6.2.8",
|
||||
|
||||
@@ -31,6 +31,7 @@ import { adminRoutes } from './routes/admin.js'
|
||||
import { auditAdminRoutes } from './routes/audit.js'
|
||||
import { auditIngestRoutes } from './routes/ingest-audit.js'
|
||||
import { oidcRoutes } from './routes/oidc.js'
|
||||
import { webauthnRoutes } from './routes/webauthn.js'
|
||||
import { startAuditRetentionJob } from './services/audit-retention.js'
|
||||
import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js'
|
||||
|
||||
@@ -129,6 +130,7 @@ export async function buildApp(opts: {
|
||||
await app.register(auditAdminRoutes)
|
||||
await app.register(auditIngestRoutes)
|
||||
await app.register(oidcRoutes)
|
||||
await app.register(webauthnRoutes)
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const stopRetention = startAuditRetentionJob(app)
|
||||
|
||||
@@ -24,6 +24,9 @@ export const configSchema = z.object({
|
||||
logLevel: z.string().default('info'),
|
||||
isProd: z.boolean(),
|
||||
auditIngestSecret: z.string().min(8).optional(),
|
||||
webauthnRpID: z.string().min(1),
|
||||
webauthnRpName: z.string().min(1).default('Auth Portal'),
|
||||
webauthnOrigins: z.array(z.string().url()).min(1),
|
||||
})
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema>
|
||||
@@ -35,6 +38,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
env.AUDIT_INGEST_SECRET ??
|
||||
(isProd ? undefined : 'dev-audit-ingest-secret')
|
||||
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
|
||||
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
|
||||
|
||||
return configSchema.parse({
|
||||
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
|
||||
@@ -56,9 +60,43 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
|
||||
isProd,
|
||||
auditIngestSecret,
|
||||
webauthnRpID: rpID,
|
||||
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
|
||||
webauthnOrigins: origins,
|
||||
})
|
||||
}
|
||||
|
||||
function webauthnFromIssuer(
|
||||
issuer: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
isProd: boolean,
|
||||
): { rpID: string; origins: string[] } {
|
||||
let issuerUrl: URL
|
||||
try {
|
||||
issuerUrl = new URL(issuer)
|
||||
} catch {
|
||||
issuerUrl = new URL('https://auth.shnt.top')
|
||||
}
|
||||
const rpID = (env.WEBAUTHN_RP_ID || issuerUrl.hostname).trim()
|
||||
const origins = new Set<string>()
|
||||
origins.add(issuerUrl.origin)
|
||||
const extra = env.WEBAUTHN_ORIGINS ?? ''
|
||||
for (const raw of extra.split(',')) {
|
||||
const value = raw.trim().replace(/\/$/, '')
|
||||
if (!value) continue
|
||||
try {
|
||||
origins.add(new URL(value).origin)
|
||||
} catch {
|
||||
/* skip invalid */
|
||||
}
|
||||
}
|
||||
if (!isProd) {
|
||||
origins.add('http://localhost:5173')
|
||||
origins.add('http://localhost:8080')
|
||||
}
|
||||
return { rpID, origins: [...origins] }
|
||||
}
|
||||
|
||||
export function oidcIssuerFromConfig(config: AppConfig): string {
|
||||
return (config.oidcIssuer ?? config.issuer).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
createRefreshSession,
|
||||
touchLastLogin,
|
||||
type UserRow,
|
||||
} from '@authportal/db'
|
||||
import { issueAccessToken } from './issue-access-token.js'
|
||||
import { safeAudit } from './audit.js'
|
||||
import { targetAppFromReturnTo } from './target-app.js'
|
||||
|
||||
export const REFRESH_COOKIE = 'refresh_token'
|
||||
|
||||
export function completeLogin(
|
||||
app: FastifyInstance,
|
||||
reply: FastifyReply,
|
||||
user: UserRow,
|
||||
opts: {
|
||||
method: 'password' | 'passkey'
|
||||
returnTo?: string
|
||||
ip: string | null
|
||||
userAgent: string | null
|
||||
},
|
||||
) {
|
||||
const body = issueAccessToken(app, user)
|
||||
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, {
|
||||
ip: opts.ip,
|
||||
userAgent: opts.userAgent,
|
||||
})
|
||||
touchLastLogin(app.db, user.id, opts.ip)
|
||||
|
||||
reply.header(
|
||||
'Set-Cookie',
|
||||
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
|
||||
)
|
||||
|
||||
const targetApp = targetAppFromReturnTo(opts.returnTo)
|
||||
const isPasskey = opts.method === 'passkey'
|
||||
safeAudit(app, {
|
||||
action: isPasskey ? 'auth.passkey_login' : 'auth.login',
|
||||
severity: 'info',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: isPasskey
|
||||
? `Вход (passkey): ${user.email}`
|
||||
: `Вход: ${user.email}`,
|
||||
details: {
|
||||
method: opts.method,
|
||||
user_agent: opts.userAgent,
|
||||
return_to: opts.returnTo ?? null,
|
||||
target_app: targetApp,
|
||||
},
|
||||
ip: opts.ip,
|
||||
})
|
||||
|
||||
return body
|
||||
}
|
||||
@@ -51,6 +51,20 @@ export function targetAppFromReturnTo(
|
||||
) {
|
||||
return 'dns'
|
||||
}
|
||||
if (
|
||||
/\bcdn\b/.test(hay) ||
|
||||
host.includes('cdnmanager') ||
|
||||
host.includes('cdn-manager')
|
||||
) {
|
||||
return 'cdn'
|
||||
}
|
||||
if (
|
||||
/\bmm\b/.test(hay) ||
|
||||
host.includes('mikrotik') ||
|
||||
host.includes('mmapp')
|
||||
) {
|
||||
return 'mm'
|
||||
}
|
||||
return 'portal'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'
|
||||
import type { AuthenticatorTransportFuture } from '@simplewebauthn/server'
|
||||
|
||||
export function webauthnRelyingParty(app: FastifyInstance): {
|
||||
rpID: string
|
||||
rpName: string
|
||||
origins: string[]
|
||||
} {
|
||||
return {
|
||||
rpID: app.config.webauthnRpID,
|
||||
rpName: app.config.webauthnRpName,
|
||||
origins: app.config.webauthnOrigins,
|
||||
}
|
||||
}
|
||||
|
||||
export function userIdToBytes(userId: string): Uint8Array {
|
||||
return isoUint8Array.fromUTF8String(userId)
|
||||
}
|
||||
|
||||
export function encodePublicKey(publicKey: Uint8Array): string {
|
||||
return isoBase64URL.fromBuffer(publicKey)
|
||||
}
|
||||
|
||||
export function decodePublicKey(stored: string): Uint8Array {
|
||||
return isoBase64URL.toBuffer(stored)
|
||||
}
|
||||
|
||||
export function asTransports(
|
||||
values: string[],
|
||||
): AuthenticatorTransportFuture[] | undefined {
|
||||
if (values.length === 0) return undefined
|
||||
return values as AuthenticatorTransportFuture[]
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
listOidcClients,
|
||||
parseJsonStringArray,
|
||||
updateOidcClient,
|
||||
countWebauthnCredentials,
|
||||
deleteWebauthnCredentialsForUser,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
APP_IDS,
|
||||
@@ -62,6 +64,7 @@ function mapUser(
|
||||
permissions,
|
||||
last_login_at: user.lastLoginAt ?? null,
|
||||
last_login_ip: user.lastLoginIp ?? null,
|
||||
passkey_count: countWebauthnCredentials(db, user.id),
|
||||
created_at: user.createdAt,
|
||||
updated_at: user.updatedAt,
|
||||
}
|
||||
@@ -217,6 +220,30 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id/passkeys',
|
||||
async (request, reply) => {
|
||||
const existing = getUserById(app.db, request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
const removed = deleteWebauthnCredentialsForUser(app.db, existing.id)
|
||||
safeAudit(app, {
|
||||
action: 'admin.passkey_reset',
|
||||
severity: 'warning',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: existing.id,
|
||||
summary: `Сброшены passkeys: ${existing.email} (${removed})`,
|
||||
details: { removed },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { ok: true, removed, user: mapUser(app.db, existing) }
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id',
|
||||
async (request, reply) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { verify } from '@node-rs/argon2'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
PERMISSION_CATALOG,
|
||||
appsMetaFromSwitcher,
|
||||
@@ -9,26 +8,24 @@ import {
|
||||
ssoAccessRequestSchema,
|
||||
} from '@authportal/shared'
|
||||
import {
|
||||
createRefreshSession,
|
||||
getAppSwitcherConfig,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
listUsers,
|
||||
revokeRefreshSession,
|
||||
touchLastLogin,
|
||||
} from '@authportal/db'
|
||||
import { requireAuth } from '../plugins/auth-guards.js'
|
||||
import { issueAccessToken } from '../lib/issue-access-token.js'
|
||||
import { completeLogin, REFRESH_COOKIE } from '../lib/complete-login.js'
|
||||
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token'
|
||||
|
||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
/** Public — SPA reads allowlist at runtime (Docker-friendly). */
|
||||
app.get('/api/v1/auth/config', async () => ({
|
||||
return_to_allowlist: app.config.returnToAllowlist,
|
||||
issuer: app.config.issuer,
|
||||
webauthn: true,
|
||||
}))
|
||||
|
||||
app.post('/api/v1/auth/login', {
|
||||
@@ -90,41 +87,12 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
const body = issueAccessToken(app, user)
|
||||
|
||||
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, {
|
||||
return completeLogin(app, reply, user, {
|
||||
method: 'password',
|
||||
returnTo,
|
||||
ip,
|
||||
userAgent,
|
||||
})
|
||||
touchLastLogin(app.db, user.id, ip)
|
||||
|
||||
reply.header(
|
||||
'Set-Cookie',
|
||||
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
|
||||
)
|
||||
|
||||
safeAudit(app, {
|
||||
action: 'auth.login',
|
||||
severity: 'info',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: `Вход: ${user.email}`,
|
||||
details: {
|
||||
user_agent: userAgent,
|
||||
return_to: returnTo ?? null,
|
||||
target_app: targetApp,
|
||||
},
|
||||
ip,
|
||||
})
|
||||
|
||||
return body
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
type AuthenticationResponseJSON,
|
||||
type RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/server'
|
||||
import {
|
||||
consumeWebauthnChallenge,
|
||||
createWebauthnChallenge,
|
||||
createWebauthnCredential,
|
||||
deleteWebauthnCredential,
|
||||
getUserById,
|
||||
getWebauthnCredentialByCredentialId,
|
||||
getWebauthnCredentialById,
|
||||
listWebauthnCredentials,
|
||||
parseTransportsJson,
|
||||
updateWebauthnCredentialName,
|
||||
touchWebauthnCredential,
|
||||
type WebauthnCredentialRow,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
patchPasskeyRequestSchema,
|
||||
webauthnVerifyRequestSchema,
|
||||
type PasskeyCredential,
|
||||
} from '@authportal/shared'
|
||||
import { requireAuth } from '../plugins/auth-guards.js'
|
||||
import { completeLogin } from '../lib/complete-login.js'
|
||||
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
||||
import {
|
||||
asTransports,
|
||||
decodePublicKey,
|
||||
encodePublicKey,
|
||||
userIdToBytes,
|
||||
webauthnRelyingParty,
|
||||
} from '../lib/webauthn.js'
|
||||
|
||||
const LOGIN_RATE = { max: 20, timeWindow: '1 minute' } as const
|
||||
|
||||
function toPasskeyDto(row: WebauthnCredentialRow): PasskeyCredential {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
created_at: row.createdAt,
|
||||
last_used_at: row.lastUsedAt ?? null,
|
||||
device_type: row.deviceType ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function defaultPasskeyName(userAgent: string | null): string {
|
||||
const date = new Date().toLocaleDateString('ru-RU')
|
||||
if (!userAgent) return `Passkey ${date}`
|
||||
if (/iPhone|iPad|Macintosh/i.test(userAgent)) return `Apple ${date}`
|
||||
if (/Windows/i.test(userAgent)) return `Windows Hello ${date}`
|
||||
if (/Android/i.test(userAgent)) return `Android ${date}`
|
||||
return `Passkey ${date}`
|
||||
}
|
||||
|
||||
function asRegistrationResponse(
|
||||
raw: unknown,
|
||||
): RegistrationResponseJSON | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
|
||||
return null
|
||||
}
|
||||
return raw as RegistrationResponseJSON
|
||||
}
|
||||
|
||||
function asAuthenticationResponse(
|
||||
raw: unknown,
|
||||
): AuthenticationResponseJSON | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
|
||||
return null
|
||||
}
|
||||
return raw as AuthenticationResponseJSON
|
||||
}
|
||||
|
||||
export async function webauthnRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post(
|
||||
'/api/v1/webauthn/register/options',
|
||||
{
|
||||
onRequest: requireAuth,
|
||||
config: { rateLimit: LOGIN_RATE },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const auth = request.authUser!
|
||||
const user = getUserById(app.db, auth.id)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||
})
|
||||
}
|
||||
const rp = webauthnRelyingParty(app)
|
||||
const existing = listWebauthnCredentials(app.db, user.id)
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: rp.rpName,
|
||||
rpID: rp.rpID,
|
||||
userName: user.email,
|
||||
userDisplayName: user.name,
|
||||
userID: userIdToBytes(user.id),
|
||||
attestationType: 'none',
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
excludeCredentials: existing.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: asTransports(parseTransportsJson(cred.transportsJson)),
|
||||
})),
|
||||
})
|
||||
const row = createWebauthnChallenge(app.db, {
|
||||
purpose: 'register',
|
||||
challenge: options.challenge,
|
||||
userId: user.id,
|
||||
})
|
||||
return { challenge_id: row.id, options }
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/api/v1/webauthn/register',
|
||||
{
|
||||
onRequest: requireAuth,
|
||||
config: { rateLimit: LOGIN_RATE },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const auth = request.authUser!
|
||||
const user = getUserById(app.db, auth.id)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||
})
|
||||
}
|
||||
const response = asRegistrationResponse(parsed.data.response)
|
||||
if (!response) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректный ответ passkey' },
|
||||
})
|
||||
}
|
||||
const challenge = consumeWebauthnChallenge(
|
||||
app.db,
|
||||
parsed.data.challenge_id,
|
||||
'register',
|
||||
user.id,
|
||||
)
|
||||
if (!challenge) {
|
||||
return reply.status(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Срок действия challenge истёк, повторите регистрацию',
|
||||
},
|
||||
})
|
||||
}
|
||||
const rp = webauthnRelyingParty(app)
|
||||
let verification
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: rp.origins,
|
||||
expectedRPID: rp.rpID,
|
||||
requireUserVerification: true,
|
||||
})
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'webauthn register verify failed')
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Не удалось проверить passkey' },
|
||||
})
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Passkey не подтверждён' },
|
||||
})
|
||||
}
|
||||
const info = verification.registrationInfo
|
||||
const duplicate = getWebauthnCredentialByCredentialId(
|
||||
app.db,
|
||||
info.credential.id,
|
||||
)
|
||||
if (duplicate) {
|
||||
return reply.status(409).send({
|
||||
error: { code: 'CONFLICT', message: 'Этот passkey уже зарегистрирован' },
|
||||
})
|
||||
}
|
||||
const name =
|
||||
parsed.data.name?.trim() ||
|
||||
defaultPasskeyName(clientUserAgent(request.headers))
|
||||
const row = createWebauthnCredential(app.db, {
|
||||
userId: user.id,
|
||||
credentialId: info.credential.id,
|
||||
publicKey: encodePublicKey(info.credential.publicKey),
|
||||
counter: info.credential.counter,
|
||||
deviceType: info.credentialDeviceType,
|
||||
backedUp: info.credentialBackedUp,
|
||||
transports: info.credential.transports,
|
||||
name,
|
||||
})
|
||||
safeAudit(app, {
|
||||
action: 'auth.passkey_register',
|
||||
severity: 'info',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'credential',
|
||||
targetId: row.id,
|
||||
summary: `Passkey добавлен: ${user.email}`,
|
||||
details: { name: row.name, device_type: row.deviceType },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return toPasskeyDto(row)
|
||||
},
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/api/v1/webauthn/credentials',
|
||||
{ onRequest: requireAuth },
|
||||
async (request) => {
|
||||
const auth = request.authUser!
|
||||
return listWebauthnCredentials(app.db, auth.id).map(toPasskeyDto)
|
||||
},
|
||||
)
|
||||
|
||||
app.patch<{ Params: { id: string } }>(
|
||||
'/api/v1/webauthn/credentials/:id',
|
||||
{ onRequest: requireAuth },
|
||||
async (request, reply) => {
|
||||
const parsed = patchPasskeyRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const auth = request.authUser!
|
||||
const row = updateWebauthnCredentialName(
|
||||
app.db,
|
||||
request.params.id,
|
||||
auth.id,
|
||||
parsed.data.name.trim(),
|
||||
)
|
||||
if (!row) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
|
||||
})
|
||||
}
|
||||
return toPasskeyDto(row)
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/webauthn/credentials/:id',
|
||||
{ onRequest: requireAuth },
|
||||
async (request, reply) => {
|
||||
const auth = request.authUser!
|
||||
const existing = getWebauthnCredentialById(app.db, request.params.id)
|
||||
if (!existing || existing.userId !== auth.id) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
|
||||
})
|
||||
}
|
||||
deleteWebauthnCredential(app.db, request.params.id, auth.id)
|
||||
const user = getUserById(app.db, auth.id)
|
||||
safeAudit(app, {
|
||||
action: 'auth.passkey_delete',
|
||||
severity: 'warning',
|
||||
actorUserId: auth.id,
|
||||
actorEmail: auth.email,
|
||||
actorName: auth.name,
|
||||
targetType: 'credential',
|
||||
targetId: request.params.id,
|
||||
summary: `Passkey удалён: ${user?.email ?? auth.email}`,
|
||||
details: { name: existing.name },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/api/v1/webauthn/login/options',
|
||||
{ config: { rateLimit: LOGIN_RATE } },
|
||||
async () => {
|
||||
const rp = webauthnRelyingParty(app)
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rp.rpID,
|
||||
userVerification: 'preferred',
|
||||
})
|
||||
const row = createWebauthnChallenge(app.db, {
|
||||
purpose: 'authenticate',
|
||||
challenge: options.challenge,
|
||||
})
|
||||
return { challenge_id: row.id, options }
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/api/v1/webauthn/login',
|
||||
{ config: { rateLimit: LOGIN_RATE } },
|
||||
async (request, reply) => {
|
||||
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const ip = clientIp(request)
|
||||
const userAgent = clientUserAgent(request.headers)
|
||||
const returnTo = parsed.data.return_to
|
||||
const fail = (reason: string) => {
|
||||
safeAudit(app, {
|
||||
action: 'auth.passkey_login_failed',
|
||||
severity: 'warning',
|
||||
targetType: 'session',
|
||||
summary: 'Неудачный вход по passkey',
|
||||
details: {
|
||||
reason,
|
||||
user_agent: userAgent,
|
||||
return_to: returnTo ?? null,
|
||||
target_app: targetAppFromReturnTo(returnTo),
|
||||
},
|
||||
ip,
|
||||
})
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Не удалось войти с passkey' },
|
||||
})
|
||||
}
|
||||
|
||||
const response = asAuthenticationResponse(parsed.data.response)
|
||||
if (!response) return fail('bad_response')
|
||||
|
||||
const challenge = consumeWebauthnChallenge(
|
||||
app.db,
|
||||
parsed.data.challenge_id,
|
||||
'authenticate',
|
||||
)
|
||||
if (!challenge) return fail('expired_challenge')
|
||||
|
||||
const cred = getWebauthnCredentialByCredentialId(app.db, response.id)
|
||||
if (!cred) return fail('unknown_credential')
|
||||
|
||||
const user = getUserById(app.db, cred.userId)
|
||||
if (!user || user.disabled) return fail(user ? 'disabled' : 'unknown_user')
|
||||
|
||||
const rp = webauthnRelyingParty(app)
|
||||
let verification
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: rp.origins,
|
||||
expectedRPID: rp.rpID,
|
||||
requireUserVerification: true,
|
||||
credential: {
|
||||
id: cred.credentialId,
|
||||
publicKey: decodePublicKey(cred.publicKey),
|
||||
counter: cred.counter,
|
||||
transports: asTransports(parseTransportsJson(cred.transportsJson)),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'webauthn login verify failed')
|
||||
return fail('verify_error')
|
||||
}
|
||||
if (!verification.verified) return fail('not_verified')
|
||||
|
||||
touchWebauthnCredential(
|
||||
app.db,
|
||||
cred.id,
|
||||
verification.authenticationInfo.newCounter,
|
||||
)
|
||||
return completeLogin(app, reply, user, {
|
||||
method: 'passkey',
|
||||
returnTo,
|
||||
ip,
|
||||
userAgent,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -18,11 +18,19 @@ describe('app-switcher API', () => {
|
||||
expect(body.menuLabel).toBeTruthy()
|
||||
expect(body.apps.map((a) => a.id).sort()).toEqual([
|
||||
'bgp',
|
||||
'cdn',
|
||||
'cfdm',
|
||||
'dns',
|
||||
'fw',
|
||||
'mm',
|
||||
'vps',
|
||||
])
|
||||
expect(body.apps.find((a) => a.id === 'cdn')).toMatchObject({
|
||||
authMode: 'jwt',
|
||||
})
|
||||
expect(body.apps.find((a) => a.id === 'mm')).toMatchObject({
|
||||
authMode: 'jwt',
|
||||
})
|
||||
expect(body.apps.find((a) => a.id === 'dns')).toMatchObject({
|
||||
authMode: 'oidc',
|
||||
})
|
||||
|
||||
@@ -21,4 +21,16 @@ describe('targetAppFromReturnTo', () => {
|
||||
targetAppFromReturnTo('https://auth.shnt.top/oauth/authorize'),
|
||||
).toBe('portal')
|
||||
})
|
||||
|
||||
it('maps cdn host', () => {
|
||||
expect(targetAppFromReturnTo('https://cdn.shnt.top/auth/callback')).toBe(
|
||||
'cdn',
|
||||
)
|
||||
})
|
||||
|
||||
it('maps mm host', () => {
|
||||
expect(targetAppFromReturnTo('https://mm.shnt.top/auth/callback')).toBe(
|
||||
'mm',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
import { createWebauthnCredential } from '@authportal/db'
|
||||
|
||||
async function buildTestApp() {
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_EMAIL: '[email protected]',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
ISSUER: 'https://auth.test.local',
|
||||
NODE_ENV: 'test',
|
||||
})
|
||||
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
||||
}
|
||||
|
||||
async function adminToken(app: Awaited<ReturnType<typeof buildTestApp>>) {
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: '[email protected]', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
return (login.json() as { access_token: string }).access_token
|
||||
}
|
||||
|
||||
describe('webauthn / passkeys', () => {
|
||||
it('exposes webauthn flag on auth config', async () => {
|
||||
const app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toMatchObject({ webauthn: true })
|
||||
expect(app.config.webauthnRpID).toBe('auth.test.local')
|
||||
expect(app.config.webauthnOrigins).toContain('https://auth.test.local')
|
||||
expect(app.config.webauthnOrigins).toContain('http://localhost:5173')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('requires JWT for register options', async () => {
|
||||
const app = await buildTestApp()
|
||||
const denied = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/register/options',
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
const token = await adminToken(app)
|
||||
const ok = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/register/options',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(ok.statusCode).toBe(200)
|
||||
const body = ok.json() as {
|
||||
challenge_id: string
|
||||
options: { challenge: string; rp: { id: string } }
|
||||
}
|
||||
expect(body.challenge_id).toBeTruthy()
|
||||
expect(body.options.challenge).toBeTruthy()
|
||||
expect(body.options.rp.id).toBe('auth.test.local')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('allows public login options and rejects a bogus assertion', async () => {
|
||||
const app = await buildTestApp()
|
||||
const options = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/login/options',
|
||||
})
|
||||
expect(options.statusCode).toBe(200)
|
||||
const body = options.json() as { challenge_id: string; options: unknown }
|
||||
expect(body.challenge_id).toBeTruthy()
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/login',
|
||||
payload: {
|
||||
challenge_id: body.challenge_id,
|
||||
response: { id: 'not-a-credential', type: 'public-key' },
|
||||
},
|
||||
})
|
||||
expect(login.statusCode).toBe(401)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lists, deletes own credentials and reports passkey_count', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const me = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const userId = (me.json() as { id: string }).id
|
||||
|
||||
createWebauthnCredential(app.db, {
|
||||
userId,
|
||||
credentialId: 'dGVzdC1jcmVkLWlk',
|
||||
publicKey: 'dGVzdC1wdWJrZXk',
|
||||
counter: 0,
|
||||
name: 'Test key',
|
||||
})
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/webauthn/credentials',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(listed.statusCode).toBe(200)
|
||||
const creds = listed.json() as { id: string; name: string }[]
|
||||
expect(creds).toHaveLength(1)
|
||||
expect(creds[0]?.name).toBe('Test key')
|
||||
|
||||
const users = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const admin = (
|
||||
users.json() as { email: string; passkey_count: number }[]
|
||||
).find((u) => u.email === '[email protected]')
|
||||
expect(admin?.passkey_count).toBe(1)
|
||||
|
||||
const renamed = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { name: 'Laptop' },
|
||||
})
|
||||
expect(renamed.statusCode).toBe(200)
|
||||
expect((renamed.json() as { name: string }).name).toBe('Laptop')
|
||||
|
||||
const deleted = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(deleted.statusCode).toBe(200)
|
||||
|
||||
const empty = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/webauthn/credentials',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(empty.json()).toEqual([])
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lets admin reset a user passkeys', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const me = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const userId = (me.json() as { id: string }).id
|
||||
createWebauthnCredential(app.db, {
|
||||
userId,
|
||||
credentialId: 'cmVzZXQta2V5',
|
||||
publicKey: 'cHVia2V5',
|
||||
counter: 1,
|
||||
name: 'To reset',
|
||||
})
|
||||
|
||||
const reset = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/admin/users/${userId}/passkeys`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(reset.statusCode).toBe(200)
|
||||
expect(reset.json()).toMatchObject({ ok: true, removed: 1 })
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/webauthn/credentials',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(listed.json()).toEqual([])
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-router": "^1.170.15",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
AppWindowIcon,
|
||||
FingerprintIcon,
|
||||
HistoryIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutGridIcon,
|
||||
@@ -53,6 +54,16 @@ export function AppSidebar() {
|
||||
<span>Приложения</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="Безопасность"
|
||||
isActive={isActive(pathname, '/account', true)}
|
||||
render={<Link to="/account" />}
|
||||
>
|
||||
<FingerprintIcon className="size-4" />
|
||||
<span>Безопасность</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
GlobeIcon,
|
||||
LayoutDashboardIcon,
|
||||
} from 'lucide-react'
|
||||
import { APPS, type AppId } from '@authportal/shared'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
@@ -28,6 +29,8 @@ const APP_ICONS: Record<
|
||||
bgp: NetworkIcon,
|
||||
fw: ShieldIcon,
|
||||
dns: GlobeIcon,
|
||||
cdn: CloudIcon,
|
||||
mm: LayoutDashboardIcon,
|
||||
}
|
||||
|
||||
export function AppsMenu() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ChevronsUpDownIcon,
|
||||
FingerprintIcon,
|
||||
LogOutIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
SunIcon,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@authportal/ui/components/avatar'
|
||||
@@ -109,6 +111,7 @@ export function NavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { data: me } = useQuery(meQueryOptions)
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const name = me?.name?.trim() || 'Пользователь'
|
||||
const email = me?.email?.trim() || ''
|
||||
@@ -175,6 +178,12 @@ export function NavUser() {
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void navigate({ to: '/account' })}
|
||||
>
|
||||
<FingerprintIcon aria-hidden />
|
||||
Безопасность
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<PaletteIcon aria-hidden />
|
||||
Тема
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useEffect, 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, isPortalOidcAuthorizeUrl, isReturnToAllowed } from '@authportal/shared'
|
||||
import { EyeIcon, EyeOffIcon, FingerprintIcon } from 'lucide-react'
|
||||
import {
|
||||
browserSupportsWebAuthn,
|
||||
browserSupportsWebAuthnAutofill,
|
||||
startAuthentication,
|
||||
WebAuthnAbortService,
|
||||
} from '@simplewebauthn/browser'
|
||||
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser'
|
||||
import {
|
||||
buildSsoRedirectUrl,
|
||||
isPortalOidcAuthorizeUrl,
|
||||
isReturnToAllowed,
|
||||
type LoginResponse,
|
||||
} 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'
|
||||
@@ -12,6 +24,7 @@ import {
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@authportal/ui/components/input-group'
|
||||
import { Separator } from '@authportal/ui/components/separator'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
@@ -20,6 +33,7 @@ import {
|
||||
import { ensureAuthConfig, setToken } from '@/lib/auth'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import { login, meQueryKey } from '@/queries/auth'
|
||||
import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn'
|
||||
import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo'
|
||||
|
||||
export function PortalLoginForm() {
|
||||
@@ -29,42 +43,110 @@ export function PortalLoginForm() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [passkeySupported, setPasskeySupported] = useState(false)
|
||||
|
||||
async function applySession(res: LoginResponse) {
|
||||
setToken(res.access_token)
|
||||
queryClient.setQueryData(meQueryKey, res.user)
|
||||
|
||||
const returnTo = search.return_to
|
||||
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
|
||||
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
|
||||
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
|
||||
window.location.href = returnTo
|
||||
return
|
||||
}
|
||||
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' })
|
||||
}
|
||||
}
|
||||
|
||||
async function runPasskeyLogin() {
|
||||
const { challenge_id, options } = await webauthnLoginOptions()
|
||||
const assertion = await startAuthentication({
|
||||
optionsJSON: options as unknown as PublicKeyCredentialRequestOptionsJSON,
|
||||
})
|
||||
const res = await webauthnLogin(challenge_id, assertion, search.return_to)
|
||||
await applySession(res)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!browserSupportsWebAuthn()) return
|
||||
setPasskeySupported(true)
|
||||
let cancelled = false
|
||||
|
||||
async function startConditional() {
|
||||
if (!(await browserSupportsWebAuthnAutofill())) return
|
||||
try {
|
||||
const { challenge_id, options } = await webauthnLoginOptions()
|
||||
if (cancelled) return
|
||||
const assertion = await startAuthentication({
|
||||
optionsJSON:
|
||||
options as unknown as PublicKeyCredentialRequestOptionsJSON,
|
||||
useBrowserAutofill: true,
|
||||
})
|
||||
if (cancelled) return
|
||||
setPending(true)
|
||||
setError(null)
|
||||
const res = await webauthnLogin(
|
||||
challenge_id,
|
||||
assertion,
|
||||
search.return_to,
|
||||
)
|
||||
await applySession(res)
|
||||
} catch {
|
||||
/* abort / unsupported / user dismissed */
|
||||
} finally {
|
||||
if (!cancelled) setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
void startConditional()
|
||||
return () => {
|
||||
cancelled = true
|
||||
WebAuthnAbortService.cancelCeremony()
|
||||
}
|
||||
// Login page mount only — return_to is stable for the visit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
WebAuthnAbortService.cancelCeremony()
|
||||
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)
|
||||
const res = await login(email, password, search.return_to)
|
||||
await applySession(res)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Не удалось войти')
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
|
||||
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
|
||||
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
|
||||
window.location.href = returnTo
|
||||
return
|
||||
}
|
||||
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' })
|
||||
}
|
||||
async function handlePasskeyClick() {
|
||||
WebAuthnAbortService.cancelCeremony()
|
||||
setError(null)
|
||||
setPending(true)
|
||||
try {
|
||||
await runPasskeyLogin()
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : 'Не удалось войти',
|
||||
err instanceof ApiError ? err.message : 'Не удалось войти с passkey',
|
||||
)
|
||||
} finally {
|
||||
setPending(false)
|
||||
@@ -101,7 +183,7 @@ export function PortalLoginForm() {
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
autoComplete="username webauthn"
|
||||
placeholder="[email protected]"
|
||||
className="bg-background"
|
||||
required
|
||||
@@ -137,6 +219,26 @@ export function PortalLoginForm() {
|
||||
{pending ? 'Вход…' : 'Войти'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{passkeySupported ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">или</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={pending}
|
||||
onClick={() => void handlePasskeyClick()}
|
||||
>
|
||||
<FingerprintIcon aria-hidden="true" />
|
||||
Войти с passkey
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CircleDotIcon,
|
||||
FilterIcon,
|
||||
FunnelXIcon,
|
||||
FingerprintIcon,
|
||||
LockIcon,
|
||||
MailIcon,
|
||||
MoreHorizontalIcon,
|
||||
@@ -115,6 +116,7 @@ export interface AdminUsersGridProps {
|
||||
onOpenAccess: (user: AdminUser) => void
|
||||
onDeactivate: (user: AdminUser) => void
|
||||
onDelete: (user: AdminUser) => void
|
||||
onResetPasskeys: (user: AdminUser) => void
|
||||
onBulkSetRole: (userIds: string[], isAdmin: boolean) => void
|
||||
onBulkDeactivate: (userIds: string[]) => void
|
||||
}
|
||||
@@ -403,14 +405,17 @@ function ActionsCell({
|
||||
onOpenAccess,
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onResetPasskeys,
|
||||
}: {
|
||||
row: Row<AdminUser>
|
||||
onOpenAudit: (user: AdminUser) => void
|
||||
onOpenAccess: (user: AdminUser) => void
|
||||
onDeactivate: (user: AdminUser) => void
|
||||
onDelete: (user: AdminUser) => void
|
||||
onResetPasskeys: (user: AdminUser) => void
|
||||
}) {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const user = row.original
|
||||
|
||||
return (
|
||||
@@ -444,6 +449,12 @@ function ActionsCell({
|
||||
Отключить
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{(user.passkey_count ?? 0) > 0 ? (
|
||||
<DropdownMenuItem onClick={() => setResetOpen(true)}>
|
||||
<FingerprintIcon className="size-4" aria-hidden="true" />
|
||||
Сбросить passkeys
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
@@ -456,6 +467,31 @@ function ActionsCell({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={resetOpen} onOpenChange={setResetOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Сбросить passkeys?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Все ключи пользователя{' '}
|
||||
<span className="text-foreground font-medium">{user.email}</span>{' '}
|
||||
будут удалены. Вход останется по паролю.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setResetOpen(false)
|
||||
onResetPasskeys(user)
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
@@ -489,6 +525,7 @@ function createAdminUserColumns(handlers: {
|
||||
onOpenAccess: (user: AdminUser) => void
|
||||
onDeactivate: (user: AdminUser) => void
|
||||
onDelete: (user: AdminUser) => void
|
||||
onResetPasskeys: (user: AdminUser) => void
|
||||
}): ColumnDef<AdminUser>[] {
|
||||
return [
|
||||
{
|
||||
@@ -614,15 +651,22 @@ function createAdminUserColumns(handlers: {
|
||||
},
|
||||
{
|
||||
id: 'twoFactor',
|
||||
accessorFn: (row) => (row.passkey_count ?? 0) > 0,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="2FA" visibility column={column} />
|
||||
),
|
||||
cell: () => (
|
||||
<Badge variant="destructive-outline">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
Выкл
|
||||
</Badge>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
(row.original.passkey_count ?? 0) > 0 ? (
|
||||
<Badge variant="success-outline">
|
||||
<FingerprintIcon aria-hidden="true" />
|
||||
Passkey
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive-outline">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
Выкл
|
||||
</Badge>
|
||||
),
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
@@ -686,6 +730,7 @@ function createAdminUserColumns(handlers: {
|
||||
onOpenAccess={handlers.onOpenAccess}
|
||||
onDeactivate={handlers.onDeactivate}
|
||||
onDelete={handlers.onDelete}
|
||||
onResetPasskeys={handlers.onResetPasskeys}
|
||||
/>
|
||||
),
|
||||
size: 60,
|
||||
@@ -710,6 +755,7 @@ export function AdminUsersGrid({
|
||||
onOpenAccess,
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onResetPasskeys,
|
||||
onBulkSetRole,
|
||||
onBulkDeactivate,
|
||||
}: AdminUsersGridProps) {
|
||||
@@ -848,8 +894,9 @@ export function AdminUsersGrid({
|
||||
onOpenAccess,
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
onResetPasskeys,
|
||||
}),
|
||||
[onOpenAudit, onOpenAccess, onDeactivate, onDelete],
|
||||
[onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys],
|
||||
)
|
||||
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(
|
||||
|
||||
@@ -43,6 +43,8 @@ export const SOURCE_APP_OPTIONS: {
|
||||
{ value: 'bgp', label: 'EvoBGP' },
|
||||
{ value: 'fw', label: 'EvoFirewall' },
|
||||
{ value: 'dns', label: 'Technitium DNS' },
|
||||
{ value: 'cdn', label: 'CDN Manager' },
|
||||
{ value: 'mm', label: 'MikrotikManager' },
|
||||
]
|
||||
|
||||
export const severityVariant: Record<AuditSeverity, BadgeProps['variant']> = {
|
||||
|
||||
@@ -16,3 +16,4 @@ export { UserAuditSheet } from './user-audit-sheet'
|
||||
export { CreateUserSheet } from './create-user-sheet'
|
||||
export { CreateOidcClientSheet } from './create-oidc-client-sheet'
|
||||
export { AdminUsersGrid, type AdminUsersGridProps } from './admin-users-grid'
|
||||
export { PasskeySettingsPanel } from './passkey-settings-panel'
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Passkey management — DNA settings-16 / settings-2 / settings-10.
|
||||
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-2 · https://reui.io/preview/base/settings-10
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { FingerprintIcon, PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import {
|
||||
startRegistration,
|
||||
browserSupportsWebAuthn,
|
||||
} from '@simplewebauthn/browser'
|
||||
import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/browser'
|
||||
import type { PasskeyCredential } from '@authportal/shared'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@authportal/ui/components/item'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@authportal/ui/components/alert-dialog'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import {
|
||||
deletePasskey,
|
||||
passkeysQueryKey,
|
||||
passkeysQueryOptions,
|
||||
webauthnRegister,
|
||||
webauthnRegisterOptions,
|
||||
} from '@/queries/webauthn'
|
||||
|
||||
function formatWhen(iso: string | null) {
|
||||
if (!iso) return 'ещё не использовался'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function PasskeySettingsPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: passkeys = [], isLoading } = useQuery(passkeysQueryOptions)
|
||||
const [pendingDelete, setPendingDelete] = useState<PasskeyCredential | null>(
|
||||
null,
|
||||
)
|
||||
const supported = browserSupportsWebAuthn()
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { challenge_id, options } = await webauthnRegisterOptions()
|
||||
const attResp = await startRegistration({
|
||||
optionsJSON:
|
||||
options as unknown as PublicKeyCredentialCreationOptionsJSON,
|
||||
})
|
||||
return webauthnRegister(challenge_id, attResp)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
|
||||
toast.success('Passkey добавлен')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось добавить passkey',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deletePasskey(id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: passkeysQueryKey })
|
||||
toast.success('Passkey удалён')
|
||||
setPendingDelete(null)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось удалить passkey',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Frame className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<FrameTitle>Passkeys</FrameTitle>
|
||||
<FrameDescription>
|
||||
Вход без пароля: Windows Hello, Face ID, ключ безопасности
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!supported || registerMutation.isPending}
|
||||
onClick={() => registerMutation.mutate()}
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{registerMutation.isPending ? 'Ожидание…' : 'Добавить passkey'}
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-2">
|
||||
{!supported ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Этот браузер не поддерживает WebAuthn.
|
||||
</p>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
</div>
|
||||
) : passkeys.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Ключи не зарегистрированы. Добавьте passkey, чтобы входить без
|
||||
пароля.
|
||||
</p>
|
||||
) : (
|
||||
passkeys.map((item) => (
|
||||
<Item key={item.id} variant="outline" className="items-start">
|
||||
<ItemMedia variant="icon">
|
||||
<FingerprintIcon className="size-4" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle className="flex flex-wrap items-center gap-2">
|
||||
{item.name}
|
||||
{item.device_type === 'multiDevice' ? (
|
||||
<Badge variant="info-outline" size="sm">
|
||||
Синхронизируется
|
||||
</Badge>
|
||||
) : null}
|
||||
</ItemTitle>
|
||||
<ItemDescription>
|
||||
Добавлен {formatWhen(item.created_at)} · вход{' '}
|
||||
{formatWhen(item.last_used_at)}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Удалить ${item.name}`}
|
||||
onClick={() => setPendingDelete(item)}
|
||||
>
|
||||
<Trash2Icon aria-hidden="true" />
|
||||
Удалить
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(pendingDelete)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingDelete(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить passkey?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingDelete
|
||||
? `«${pendingDelete.name}» больше нельзя будет использовать для входа.`
|
||||
: null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (pendingDelete) deleteMutation.mutate(pendingDelete.id)
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import type {
|
||||
LoginResponse,
|
||||
PasskeyCredential,
|
||||
WebauthnOptionsResponse,
|
||||
} from '@authportal/shared'
|
||||
import { api } from '@/lib/api-client'
|
||||
|
||||
export const passkeysQueryKey = ['webauthn', 'credentials'] as const
|
||||
|
||||
export const passkeysQueryOptions = queryOptions({
|
||||
queryKey: passkeysQueryKey,
|
||||
queryFn: () => api.get<PasskeyCredential[]>('/api/v1/webauthn/credentials'),
|
||||
})
|
||||
|
||||
export function webauthnLoginOptions() {
|
||||
return api.post<WebauthnOptionsResponse>('/api/v1/webauthn/login/options')
|
||||
}
|
||||
|
||||
export function webauthnLogin(
|
||||
challengeId: string,
|
||||
response: unknown,
|
||||
returnTo?: string,
|
||||
) {
|
||||
return api.post<LoginResponse>('/api/v1/webauthn/login', {
|
||||
challenge_id: challengeId,
|
||||
response,
|
||||
...(returnTo ? { return_to: returnTo } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export function webauthnRegisterOptions() {
|
||||
return api.post<WebauthnOptionsResponse>('/api/v1/webauthn/register/options')
|
||||
}
|
||||
|
||||
export function webauthnRegister(
|
||||
challengeId: string,
|
||||
response: unknown,
|
||||
name?: string,
|
||||
) {
|
||||
return api.post<PasskeyCredential>('/api/v1/webauthn/register', {
|
||||
challenge_id: challengeId,
|
||||
response,
|
||||
...(name ? { name } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export function renamePasskey(id: string, name: string) {
|
||||
return api.patch<PasskeyCredential>(`/api/v1/webauthn/credentials/${id}`, {
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePasskey(id: string) {
|
||||
return api.delete<{ ok: boolean }>(`/api/v1/webauthn/credentials/${id}`)
|
||||
}
|
||||
|
||||
export function adminResetPasskeys(userId: string) {
|
||||
return api.delete<{
|
||||
ok: boolean
|
||||
removed: number
|
||||
}>(`/api/v1/admin/users/${userId}/passkeys`)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as AuthRouteImport } from './routes/_auth'
|
||||
import { Route as LogoutRouteImport } from './routes/logout'
|
||||
import { Route as AuthAccountRouteImport } from './routes/_auth.account'
|
||||
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'
|
||||
@@ -35,6 +36,11 @@ const LogoutRoute = LogoutRouteImport.update({
|
||||
path: '/logout',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthAccountRoute = AuthAccountRouteImport.update({
|
||||
id: '/account',
|
||||
path: '/account',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthAdminRoute = AuthAdminRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
@@ -79,6 +85,7 @@ const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/logout': typeof LogoutRoute
|
||||
'/account': typeof AuthAccountRoute
|
||||
'/admin': typeof AuthAdminRouteWithChildren
|
||||
'/apps': typeof AuthAppsRoute
|
||||
'/admin/apps': typeof AuthAdminAppsRoute
|
||||
@@ -91,6 +98,7 @@ export interface FileRoutesByFullPath {
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/logout': typeof LogoutRoute
|
||||
'/account': typeof AuthAccountRoute
|
||||
'/apps': typeof AuthAppsRoute
|
||||
'/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/admin/audit': typeof AuthAdminAuditRoute
|
||||
@@ -104,6 +112,7 @@ export interface FileRoutesById {
|
||||
'/': typeof IndexRoute
|
||||
'/_auth': typeof AuthRouteWithChildren
|
||||
'/logout': typeof LogoutRoute
|
||||
'/_auth/account': typeof AuthAccountRoute
|
||||
'/_auth/admin': typeof AuthAdminRouteWithChildren
|
||||
'/_auth/apps': typeof AuthAppsRoute
|
||||
'/_auth/admin/apps': typeof AuthAdminAppsRoute
|
||||
@@ -118,6 +127,7 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/logout'
|
||||
| '/account'
|
||||
| '/admin'
|
||||
| '/apps'
|
||||
| '/admin/apps'
|
||||
@@ -130,6 +140,7 @@ export interface FileRouteTypes {
|
||||
to:
|
||||
| '/'
|
||||
| '/logout'
|
||||
| '/account'
|
||||
| '/apps'
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
@@ -142,6 +153,7 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/_auth'
|
||||
| '/logout'
|
||||
| '/_auth/account'
|
||||
| '/_auth/admin'
|
||||
| '/_auth/apps'
|
||||
| '/_auth/admin/apps'
|
||||
@@ -181,6 +193,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LogoutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/account': {
|
||||
id: '/_auth/account'
|
||||
path: '/account'
|
||||
fullPath: '/account'
|
||||
preLoaderRoute: typeof AuthAccountRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/admin': {
|
||||
id: '/_auth/admin'
|
||||
path: '/admin'
|
||||
@@ -263,11 +282,13 @@ const AuthAdminRouteWithChildren = AuthAdminRoute._addFileChildren(
|
||||
)
|
||||
|
||||
interface AuthRouteChildren {
|
||||
AuthAccountRoute: typeof AuthAccountRoute
|
||||
AuthAdminRoute: typeof AuthAdminRouteWithChildren
|
||||
AuthAppsRoute: typeof AuthAppsRoute
|
||||
}
|
||||
|
||||
const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthAccountRoute: AuthAccountRoute,
|
||||
AuthAdminRoute: AuthAdminRouteWithChildren,
|
||||
AuthAppsRoute: AuthAppsRoute,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Account security — passkeys.
|
||||
* Preview: https://reui.io/preview/base/settings-10 · https://reui.io/preview/base/settings-16
|
||||
*/
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PasskeySettingsPanel } from '@/components/reui-kit/passkey-settings-panel'
|
||||
|
||||
export const Route = createFileRoute('/_auth/account')({
|
||||
component: AccountPage,
|
||||
})
|
||||
|
||||
function AccountPage() {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Безопасность</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Passkey как альтернатива паролю. Пароль остаётся запасным входом.
|
||||
</p>
|
||||
</div>
|
||||
<PasskeySettingsPanel />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { UserAccessSheet } from '@/components/reui-kit/user-access-sheet'
|
||||
import { UserAuditSheet } from '@/components/reui-kit/user-audit-sheet'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { usersQueryKey, usersQueryOptions } from '@/queries/auth'
|
||||
import { adminResetPasskeys } from '@/queries/webauthn'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
|
||||
export const Route = createFileRoute('/_auth/admin/')({
|
||||
@@ -104,6 +105,29 @@ function AdminUsersPage() {
|
||||
[deleteMutation],
|
||||
)
|
||||
|
||||
const resetPasskeysMutation = useMutation({
|
||||
mutationFn: (id: string) => adminResetPasskeys(id),
|
||||
onSuccess: async (_data, id) => {
|
||||
await invalidateUsers()
|
||||
const user = users.find((u) => u.id === id)
|
||||
toast.success('Passkeys сброшены', {
|
||||
description: user?.email,
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось сбросить passkeys',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const handleResetPasskeys = useCallback(
|
||||
(user: AdminUser) => {
|
||||
resetPasskeysMutation.mutate(user.id)
|
||||
},
|
||||
[resetPasskeysMutation],
|
||||
)
|
||||
|
||||
const handleBulkSetRole = useCallback(
|
||||
(userIds: string[], isAdmin: boolean) => {
|
||||
Promise.all(
|
||||
@@ -170,6 +194,7 @@ function AdminUsersPage() {
|
||||
onOpenAccess={(user) => setAccessUserId(user.id)}
|
||||
onDeactivate={handleDeactivate}
|
||||
onDelete={handleDelete}
|
||||
onResetPasskeys={handleResetPasskeys}
|
||||
onBulkSetRole={handleBulkSetRole}
|
||||
onBulkDeactivate={handleBulkDeactivate}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
CloudIcon,
|
||||
GlobeIcon,
|
||||
LayoutDashboardIcon,
|
||||
LayoutGridIcon,
|
||||
ServerIcon,
|
||||
ShieldIcon,
|
||||
@@ -30,6 +31,8 @@ const APP_ICONS: Record<AppId, typeof CloudIcon> = {
|
||||
bgp: GlobeIcon,
|
||||
fw: ShieldIcon,
|
||||
dns: GlobeIcon,
|
||||
cdn: CloudIcon,
|
||||
mm: LayoutDashboardIcon,
|
||||
}
|
||||
|
||||
async function openApp(
|
||||
|
||||
@@ -24,6 +24,10 @@ [email protected]
|
||||
ADMIN_PASSWORD=
|
||||
ADMIN_NAME=Admin
|
||||
# Include Technitium origin if used (e.g. https://dns.shnt.top)
|
||||
RETURN_TO_ALLOWLIST=.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top
|
||||
RETURN_TO_ALLOWLIST=.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top,https://cdn.shnt.top
|
||||
LOG_LEVEL=info
|
||||
NODE_ENV=production
|
||||
# WebAuthn / passkeys (defaults from ISSUER hostname + origin)
|
||||
# WEBAUTHN_RP_ID=auth.shnt.top
|
||||
# WEBAUTHN_ORIGINS=https://auth.shnt.top
|
||||
# WEBAUTHN_RP_NAME=Auth Portal
|
||||
|
||||
@@ -179,7 +179,7 @@ VITE_AUTH_ENABLED=true
|
||||
VITE_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
```
|
||||
|
||||
См. [integrate-vps-tracker.md](integrate-vps-tracker.md), [integrate-cfdm.md](integrate-cfdm.md), [integrate-evobgp.md](integrate-evobgp.md), [integrate-evofirewall.md](integrate-evofirewall.md).
|
||||
См. [integrate-vps-tracker.md](integrate-vps-tracker.md), [integrate-cfdm.md](integrate-cfdm.md), [integrate-evobgp.md](integrate-evobgp.md), [integrate-evofirewall.md](integrate-evofirewall.md), [integrate-cdnmanager.md](integrate-cdnmanager.md), [integrate-mikrotikmanager.md](integrate-mikrotikmanager.md).
|
||||
Logout SSO: `https://auth.shnt.top/logout`.
|
||||
|
||||
### Technitium DNS (OIDC)
|
||||
|
||||
@@ -36,7 +36,7 @@ Content-Type: application/json
|
||||
```
|
||||
|
||||
- `events`: 1–50 за запрос
|
||||
- `source_app`: `vps` | `cfdm` | `bgp` | `fw` (не `portal`)
|
||||
- `source_app`: `vps` | `cfdm` | `bgp` | `fw` | `dns` | `cdn` | `mm` (не `portal`)
|
||||
- `event_id`: идемпотентность (дубликаты → `duplicates++`)
|
||||
- Ответ: `{ "accepted": N, "duplicates": M }`
|
||||
|
||||
@@ -45,7 +45,7 @@ Content-Type: application/json
|
||||
| Где | Переменная |
|
||||
|-----|------------|
|
||||
| auth-portal | `AUDIT_INGEST_SECRET` |
|
||||
| apps (vps / cfdm / bgp / fw) | `AUTH_PORTAL_URL` + `AUTH_AUDIT_INGEST_SECRET` (тот же секрет) |
|
||||
| apps (vps / cfdm / bgp / fw / cdn) | `AUTH_PORTAL_URL` + `AUTH_AUDIT_INGEST_SECRET` (тот же секрет) |
|
||||
|
||||
Dev default secret: `dev-audit-ingest-secret`.
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Интеграция auth-portal ↔ CDN Manager
|
||||
|
||||
Единый вход: пользователь логинится на auth-portal, получает JWT, переходит в CDNManager с токеном в URL fragment. CDNManager API проверяет JWT и права `cdn:*`.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Browser → CDNManager UI (нет token)
|
||||
→ redirect AUTH_PORTAL_URL/?return_to=…/auth/callback
|
||||
→ login
|
||||
→ redirect return_to#access_token=…
|
||||
→ CDNManager /auth/callback сохраняет token (cdnmanager_token)
|
||||
→ API Authorization: Bearer …
|
||||
```
|
||||
|
||||
Общий секрет: `JWT_SECRET` / `AUTH_JWT_SECRET` (HS256). Issuer: `ISSUER` / `AUTH_ISSUER`.
|
||||
|
||||
App id в портале: **`cdn`** (каталог permissions).
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
### 1. auth-portal
|
||||
|
||||
```bash
|
||||
cd auth-portal
|
||||
pnpm install
|
||||
# JWT_SECRET=dev-secret-change-me
|
||||
# RETURN_TO_ALLOWLIST=.shnt.top,localhost,http://localhost:5173,…,http://localhost:5176
|
||||
pnpm --filter @authportal/api dev # :8080
|
||||
pnpm --filter web dev # :5175
|
||||
```
|
||||
|
||||
В `apps/web/.env.local` (опционально для App Switcher / SSO open):
|
||||
|
||||
```env
|
||||
VITE_CDN_APP_URL=http://localhost:5176
|
||||
```
|
||||
|
||||
Bootstrap: `[email protected]` / `admin`. В админке выдайте app **cdn** и permissions `cdn:*`.
|
||||
|
||||
### 2. CDNManager
|
||||
|
||||
```bash
|
||||
cd CDNManager
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Корень / API:
|
||||
|
||||
```env
|
||||
AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=dev-secret-change-me
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=http://localhost:5175
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
```
|
||||
|
||||
`apps/web/.env.local`:
|
||||
|
||||
```env
|
||||
VITE_AUTH_ENABLED=true
|
||||
VITE_AUTH_PORTAL_URL=http://localhost:5175
|
||||
```
|
||||
|
||||
Порт Vite web — `5176` (`apps/web/vite.config.ts`). Добавьте origin в `RETURN_TO_ALLOWLIST` портала.
|
||||
|
||||
```bash
|
||||
pnpm --filter @cdnmanager/api dev
|
||||
pnpm --filter web dev
|
||||
```
|
||||
|
||||
Откройте CDNManager → редирект на portal → после логина NavUser показывает имя/email.
|
||||
|
||||
## Permissions ↔ API / UI
|
||||
|
||||
Иерархия: `admin` ⊃ `write` ⊃ `read` в рамках одной секции.
|
||||
|
||||
| Permission | API | UI |
|
||||
|------------|-----|-----|
|
||||
| `cdn:dashboard:read` | GET `/api/v1/dashboard/*`, `/topology` | `/` |
|
||||
| `cdn:nodes:read` | GET `/api/v1/nodes*`, `/locations` | `/nodes` |
|
||||
| `cdn:nodes:write` | POST/PATCH/DELETE nodes | create/edit нод |
|
||||
| `cdn:aliases:read` | GET `/api/v1/aliases*` | `/aliases` |
|
||||
| `cdn:aliases:write` | POST/PATCH aliases, retarget | create / Retarget |
|
||||
| `cdn:zones:read` | GET `/api/v1/zones*` | `/zones` |
|
||||
| `cdn:zones:write` | POST/PATCH zones, BIND export | создать зону |
|
||||
| `cdn:sync:write` | POST `…/sync`, `…/apply` | Sync / Apply |
|
||||
| `cdn:topology:read` | GET `/api/v1/topology` | `/topology` |
|
||||
| `cdn:settings:admin` | GET/PATCH `/api/v1/settings` | `/settings/*` |
|
||||
|
||||
Без app `cdn` в JWT `apps` → **403** на защищённые `/api/v1/*`.
|
||||
|
||||
`AUTH_REQUIRED=false` — локальный login (`ADMIN_*`) для тестов/dev без portal; UI `/login`.
|
||||
|
||||
## App Switcher
|
||||
|
||||
Публичный конфиг: `GET {AUTH_PORTAL_URL}/api/v1/app-switcher` (CORS open). CDNManager chrome (`AppSwitcher` / `AppsMenu`) читает его через `ensureAuthConfig().portalUrl`.
|
||||
|
||||
Редактор только на портале: **Админка → Ссылки приложений** (`/admin/apps`). В CDNManager Settings → Интеграции — read-only ссылка на портал.
|
||||
|
||||
`CURRENT_APP_ID = cdn`. Если в JWT есть `apps[]` — в меню только пересечение с каталогом.
|
||||
|
||||
## Audit ingest
|
||||
|
||||
Dual-write локального журнала в portal: [`integrate-audit-ingest.md`](./integrate-audit-ingest.md) (`source_app: cdn`).
|
||||
|
||||
## UI аккаунта
|
||||
|
||||
SidebarFooter → **NavUser**: Настройки, Тема, Выйти → `AUTH_PORTAL_URL/logout`.
|
||||
|
||||
## Logout (SSO)
|
||||
|
||||
Очистить `cdnmanager_token` → редирект на **`/logout`** портала (не на `/?return_to=…` — иначе portal сразу выдаст новый SSO-токен).
|
||||
|
||||
## Production (Docker)
|
||||
|
||||
Рекомендуется Traefik-стек в репозитории CDNManager: `docs/deploy-traefik.md`
|
||||
(`deploy/docker-compose.traefik.yml` + `deploy/env.traefik.example`).
|
||||
|
||||
Ключевые env контейнера:
|
||||
|
||||
```env
|
||||
AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=<тот же JWT_SECRET портала>
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
AUTH_AUDIT_INGEST_SECRET=<AUDIT_INGEST_SECRET портала>
|
||||
CLOUDFLARE_API_TOKEN=<Zone DNS Edit + Zone Read>
|
||||
```
|
||||
|
||||
В portal: `RETURN_TO_ALLOWLIST` включает `https://cdn.shnt.top` (или ваш origin).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Симптом | Причина |
|
||||
|---------|---------|
|
||||
| SSO loop / «Сессия не принята» | разный `JWT_SECRET` или `ISSUER` у portal и CDNManager |
|
||||
| 403 «Нет доступа к приложению» | у пользователя нет app `cdn` в portal |
|
||||
| 403 «Недостаточно прав» | нет нужного `cdn:…` permission |
|
||||
| return_to rejected | origin CDNManager не в `RETURN_TO_ALLOWLIST` |
|
||||
| «Выйти» сразу возвращает в CDNManager | клиент должен открывать `/logout`, не login с `return_to` |
|
||||
@@ -0,0 +1,112 @@
|
||||
# Интеграция auth-portal ↔ MikrotikManager
|
||||
|
||||
Единый вход: пользователь логинится на auth-portal, получает JWT, переходит в MikrotikManager с токеном в URL fragment. Backend MM проверяет JWT и права `mm:*`.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Browser → MikrotikManager UI (нет token)
|
||||
→ redirect AUTH_PORTAL_URL/?return_to=…/auth/callback
|
||||
→ login
|
||||
→ redirect return_to#access_token=…
|
||||
→ MM /auth/callback сохраняет token (mmapp_token)
|
||||
→ API Authorization: Bearer …
|
||||
```
|
||||
|
||||
Общий секрет: `JWT_SECRET` / `AUTH_JWT_SECRET` (HS256). Issuer: `ISSUER` / `AUTH_ISSUER`.
|
||||
|
||||
App id в портале: **`mm`** (каталог permissions).
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
### 1. auth-portal
|
||||
|
||||
```bash
|
||||
cd auth-portal
|
||||
pnpm install
|
||||
# JWT_SECRET=dev-secret-change-me
|
||||
# RETURN_TO_ALLOWLIST=.shnt.top,localhost,http://localhost:3000
|
||||
pnpm --filter @authportal/api dev # :8080
|
||||
pnpm --filter web dev # :5175
|
||||
```
|
||||
|
||||
Bootstrap: `[email protected]` / `admin`. В админке выдайте app **mm** и permissions `mm:*`.
|
||||
|
||||
### 2. MikrotikManager
|
||||
|
||||
```bash
|
||||
cd MikrotikManager
|
||||
npm install
|
||||
```
|
||||
|
||||
Backend `backend/.env`:
|
||||
|
||||
```env
|
||||
AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=dev-secret-change-me
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=http://localhost:5175
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
```
|
||||
|
||||
Frontend: `NEXT_PUBLIC_AUTH_PORTAL_URL=http://localhost:5175` (или runtime `GET /api/auth/config` через proxy).
|
||||
|
||||
```bash
|
||||
npm --prefix backend run dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Откройте `http://localhost:3000` → редирект на portal → после логина NavUser показывает имя/email.
|
||||
|
||||
## Permissions ↔ API / UI
|
||||
|
||||
Иерархия: `admin` ⊃ `write` ⊃ `read` в рамках одной секции.
|
||||
|
||||
| Permission | API (prefix) | UI |
|
||||
|------------|--------------|-----|
|
||||
| `mm:dashboard:read` | sidebar-counts, events (GET) | `/dashboard` |
|
||||
| `mm:servers:read` / `write` | `/api/servers*` | `/servers` |
|
||||
| `mm:filters:read` / `write` | `/api/filters*` | `/filters`, GRE |
|
||||
| `mm:bgp:read` / `write` | `/api/bgp*` | BGP |
|
||||
| `mm:uptime:read` / `write` | `/api/uptime*` | `/uptime` |
|
||||
| `mm:traffic:read` / `write` | `/api/traffic*` | `/traffic` |
|
||||
| `mm:alerts:read` / `write` | `/api/alerts*` | `/alerts` |
|
||||
| `mm:backups:read` / `write` | `/api/backups*` | `/backups` |
|
||||
| `mm:certificates:read` / `write` | `/api/certificates*` | certificates |
|
||||
| `mm:network:read` / `write` | `/api/network*`, OSPF, recursive, probes | network |
|
||||
| `mm:settings:admin` | `/api/system/*`, scheduler, evobgp settings | `/settings` |
|
||||
|
||||
Без app `mm` в JWT `apps` → **403** на защищённые `/api/*`.
|
||||
|
||||
`AUTH_REQUIRED=false` — API открыт (dev без portal).
|
||||
|
||||
## App Switcher
|
||||
|
||||
`CURRENT_APP_ID = mm`. Публичный конфиг: `GET {AUTH_PORTAL_URL}/api/v1/app-switcher`.
|
||||
|
||||
## Logout (SSO)
|
||||
|
||||
Очистить `mmapp_token` → редирект на **`{AUTH_PORTAL_URL}/logout`**.
|
||||
|
||||
## Production
|
||||
|
||||
См. общий стек CDN+MM: `deploy/docker-compose.cdn-mm.yml` / `deploy/env.cdn-mm.example`.
|
||||
|
||||
```env
|
||||
AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=<тот же JWT_SECRET портала>
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
CORS_ORIGIN=https://mm.shnt.top
|
||||
```
|
||||
|
||||
В portal: `RETURN_TO_ALLOWLIST` включает `https://mm.shnt.top` (или `.shnt.top`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Симптом | Причина |
|
||||
|---------|---------|
|
||||
| SSO loop | разный `JWT_SECRET` или `ISSUER` |
|
||||
| 403 «Нет доступа к приложению» | нет app `mm` у пользователя |
|
||||
| 403 «Недостаточно прав» | нет нужного `mm:…` |
|
||||
| return_to rejected | origin MM не в `RETURN_TO_ALLOWLIST` |
|
||||
@@ -43,7 +43,7 @@ Nav groups Auth Portal:
|
||||
- **Портал:** Приложения (`/apps`)
|
||||
- **Админ** (только `is_admin`): Пользователи (`/admin`), Журнал (`/admin/audit`), Ссылки приложений (`/admin/apps`)
|
||||
|
||||
App Switcher: `portal_settings.app_switcher_json` → public `GET /api/v1/app-switcher`, admin `GET/PUT /api/v1/admin/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw` · `dns`.
|
||||
App Switcher: `portal_settings.app_switcher_json` → public `GET /api/v1/app-switcher`, admin `GET/PUT /api/v1/admin/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw` · `dns` · `cdn`.
|
||||
|
||||
## MCP workflow
|
||||
|
||||
|
||||
@@ -182,6 +182,36 @@ export function migrateSchema(sqlite: Sqlite): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_client ON oidc_auth_codes(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_user ON oidc_auth_codes(user_id);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
device_type TEXT,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
transports_json TEXT,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
purpose TEXT NOT NULL,
|
||||
challenge TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
|
||||
ON webauthn_credentials(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
|
||||
ON webauthn_challenges(expires_at);
|
||||
`)
|
||||
}
|
||||
|
||||
export function healthCheck(sqlite: Sqlite): void {
|
||||
@@ -193,3 +223,4 @@ export * from './users.js'
|
||||
export * from './settings.js'
|
||||
export * from './audit-log.js'
|
||||
export * from './oidc.js'
|
||||
export * from './webauthn.js'
|
||||
|
||||
@@ -101,3 +101,28 @@ export const oidcSigningKeys = sqliteTable('oidc_signing_keys', {
|
||||
active: integer('active', { mode: 'boolean' }).notNull().default(true),
|
||||
createdAt: text('created_at').notNull(),
|
||||
})
|
||||
|
||||
export const webauthnCredentials = sqliteTable('webauthn_credentials', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
credentialId: text('credential_id').notNull().unique(),
|
||||
publicKey: text('public_key').notNull(),
|
||||
counter: integer('counter').notNull().default(0),
|
||||
deviceType: text('device_type'),
|
||||
backedUp: integer('backed_up', { mode: 'boolean' }).notNull().default(false),
|
||||
transportsJson: text('transports_json'),
|
||||
name: text('name').notNull(),
|
||||
createdAt: text('created_at').notNull(),
|
||||
lastUsedAt: text('last_used_at'),
|
||||
})
|
||||
|
||||
export const webauthnChallenges = sqliteTable('webauthn_challenges', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
|
||||
purpose: text('purpose').notNull(),
|
||||
challenge: text('challenge').notNull(),
|
||||
expiresAt: text('expires_at').notNull(),
|
||||
createdAt: text('created_at').notNull(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { and, eq, lt } from 'drizzle-orm'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AppDb } from './index.js'
|
||||
import { webauthnChallenges, webauthnCredentials } from './schema/index.js'
|
||||
|
||||
export type WebauthnCredentialRow = typeof webauthnCredentials.$inferSelect
|
||||
export type WebauthnChallengeRow = typeof webauthnChallenges.$inferSelect
|
||||
export type WebauthnChallengePurpose = 'register' | 'authenticate'
|
||||
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
export function purgeExpiredWebauthnChallenges(db: AppDb): void {
|
||||
const now = new Date().toISOString()
|
||||
db.delete(webauthnChallenges)
|
||||
.where(lt(webauthnChallenges.expiresAt, now))
|
||||
.run()
|
||||
}
|
||||
|
||||
export function createWebauthnChallenge(
|
||||
db: AppDb,
|
||||
input: {
|
||||
purpose: WebauthnChallengePurpose
|
||||
challenge: string
|
||||
userId?: string | null
|
||||
},
|
||||
): WebauthnChallengeRow {
|
||||
purgeExpiredWebauthnChallenges(db)
|
||||
const now = new Date()
|
||||
const id = randomUUID()
|
||||
db.insert(webauthnChallenges)
|
||||
.values({
|
||||
id,
|
||||
userId: input.userId ?? null,
|
||||
purpose: input.purpose,
|
||||
challenge: input.challenge,
|
||||
expiresAt: new Date(now.getTime() + CHALLENGE_TTL_MS).toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
})
|
||||
.run()
|
||||
return getWebauthnChallengeById(db, id)!
|
||||
}
|
||||
|
||||
export function getWebauthnChallengeById(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
): WebauthnChallengeRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
.from(webauthnChallenges)
|
||||
.where(eq(webauthnChallenges.id, id))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function consumeWebauthnChallenge(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
purpose: WebauthnChallengePurpose,
|
||||
userId?: string | null,
|
||||
): WebauthnChallengeRow | undefined {
|
||||
const row = getWebauthnChallengeById(db, id)
|
||||
if (!row) return undefined
|
||||
if (row.purpose !== purpose) return undefined
|
||||
if (row.expiresAt < new Date().toISOString()) {
|
||||
db.delete(webauthnChallenges).where(eq(webauthnChallenges.id, id)).run()
|
||||
return undefined
|
||||
}
|
||||
if (userId != null && row.userId && row.userId !== userId) return undefined
|
||||
db.delete(webauthnChallenges).where(eq(webauthnChallenges.id, id)).run()
|
||||
return row
|
||||
}
|
||||
|
||||
export function listWebauthnCredentials(
|
||||
db: AppDb,
|
||||
userId: string,
|
||||
): WebauthnCredentialRow[] {
|
||||
return db
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId))
|
||||
.all()
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
||||
}
|
||||
|
||||
export function countWebauthnCredentials(db: AppDb, userId: string): number {
|
||||
return listWebauthnCredentials(db, userId).length
|
||||
}
|
||||
|
||||
export function getWebauthnCredentialById(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
): WebauthnCredentialRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.id, id))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function getWebauthnCredentialByCredentialId(
|
||||
db: AppDb,
|
||||
credentialId: string,
|
||||
): WebauthnCredentialRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.credentialId, credentialId))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function createWebauthnCredential(
|
||||
db: AppDb,
|
||||
input: {
|
||||
userId: string
|
||||
credentialId: string
|
||||
publicKey: string
|
||||
counter: number
|
||||
deviceType?: string | null
|
||||
backedUp?: boolean
|
||||
transports?: string[]
|
||||
name: string
|
||||
},
|
||||
): WebauthnCredentialRow {
|
||||
const id = randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
db.insert(webauthnCredentials)
|
||||
.values({
|
||||
id,
|
||||
userId: input.userId,
|
||||
credentialId: input.credentialId,
|
||||
publicKey: input.publicKey,
|
||||
counter: input.counter,
|
||||
deviceType: input.deviceType ?? null,
|
||||
backedUp: input.backedUp ?? false,
|
||||
transportsJson: input.transports
|
||||
? JSON.stringify(input.transports)
|
||||
: null,
|
||||
name: input.name,
|
||||
createdAt: now,
|
||||
lastUsedAt: null,
|
||||
})
|
||||
.run()
|
||||
return getWebauthnCredentialById(db, id)!
|
||||
}
|
||||
|
||||
export function updateWebauthnCredentialName(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
userId: string,
|
||||
name: string,
|
||||
): WebauthnCredentialRow | undefined {
|
||||
const existing = getWebauthnCredentialById(db, id)
|
||||
if (!existing || existing.userId !== userId) return undefined
|
||||
db.update(webauthnCredentials)
|
||||
.set({ name })
|
||||
.where(
|
||||
and(
|
||||
eq(webauthnCredentials.id, id),
|
||||
eq(webauthnCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
return getWebauthnCredentialById(db, id)
|
||||
}
|
||||
|
||||
export function touchWebauthnCredential(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
counter: number,
|
||||
): void {
|
||||
db.update(webauthnCredentials)
|
||||
.set({
|
||||
counter,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(webauthnCredentials.id, id))
|
||||
.run()
|
||||
}
|
||||
|
||||
export function deleteWebauthnCredential(
|
||||
db: AppDb,
|
||||
id: string,
|
||||
userId: string,
|
||||
): boolean {
|
||||
const result = db
|
||||
.delete(webauthnCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(webauthnCredentials.id, id),
|
||||
eq(webauthnCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function deleteWebauthnCredentialsForUser(
|
||||
db: AppDb,
|
||||
userId: string,
|
||||
): number {
|
||||
const result = db
|
||||
.delete(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId))
|
||||
.run()
|
||||
return result.changes
|
||||
}
|
||||
|
||||
export function parseTransportsJson(raw: string | null): string[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const v = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(v)) return []
|
||||
return v.filter((x): x is string => typeof x === 'string')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ const DEFAULT_ICONS: Record<AppId, AppSwitcherIconName> = {
|
||||
bgp: 'globe',
|
||||
fw: 'server',
|
||||
dns: 'globe',
|
||||
cdn: 'cloud',
|
||||
mm: 'dashboard',
|
||||
}
|
||||
|
||||
const DEFAULT_AUTH_MODE: Record<AppId, AppAuthMode> = {
|
||||
@@ -55,6 +57,8 @@ const DEFAULT_AUTH_MODE: Record<AppId, AppAuthMode> = {
|
||||
bgp: 'jwt',
|
||||
fw: 'jwt',
|
||||
dns: 'oidc',
|
||||
cdn: 'jwt',
|
||||
mm: 'jwt',
|
||||
}
|
||||
|
||||
/** Seed / fallback when DB is empty. */
|
||||
|
||||
@@ -11,6 +11,8 @@ export const AUDIT_SOURCE_APPS = [
|
||||
'bgp',
|
||||
'fw',
|
||||
'dns',
|
||||
'cdn',
|
||||
'mm',
|
||||
] as const
|
||||
export type AuditSourceApp = (typeof AUDIT_SOURCE_APPS)[number]
|
||||
export const auditSourceAppSchema = z.enum(AUDIT_SOURCE_APPS)
|
||||
@@ -106,7 +108,7 @@ export type AuditPurgeResponse = z.infer<typeof auditPurgeResponseSchema>
|
||||
|
||||
export const ingestAuditEventSchema = z.object({
|
||||
event_id: z.string().min(1).max(128),
|
||||
source_app: z.enum(['vps', 'cfdm', 'bgp', 'fw', 'dns']),
|
||||
source_app: z.enum(['vps', 'cfdm', 'bgp', 'fw', 'dns', 'cdn', 'mm']),
|
||||
action: z.string().min(1).max(200),
|
||||
severity: auditSeveritySchema.optional(),
|
||||
actor_user_id: z.string().nullable().optional(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const APP_IDS = ['cfdm', 'vps', 'bgp', 'fw', 'dns'] as const
|
||||
export const APP_IDS = ['cfdm', 'vps', 'bgp', 'fw', 'dns', 'cdn', 'mm'] as const
|
||||
export type AppId = (typeof APP_IDS)[number]
|
||||
export const appIdSchema = z.enum(APP_IDS)
|
||||
|
||||
@@ -57,6 +57,20 @@ export const APPS: AppMeta[] = [
|
||||
url: 'https://dns.shnt.top',
|
||||
authMode: 'oidc',
|
||||
},
|
||||
{
|
||||
id: 'cdn',
|
||||
title: 'CDN Manager',
|
||||
description: 'Флот DNS: ноды, алиасы, sync Cloudflare',
|
||||
url: 'https://cdn.shnt.top',
|
||||
authMode: 'jwt',
|
||||
},
|
||||
{
|
||||
id: 'mm',
|
||||
title: 'MikrotikManager',
|
||||
description: 'MikroTik: серверы, фильтры, BGP, uptime',
|
||||
url: 'https://mm.shnt.top',
|
||||
authMode: 'jwt',
|
||||
},
|
||||
]
|
||||
|
||||
export type CatalogSection = {
|
||||
@@ -154,6 +168,36 @@ export const PERMISSION_CATALOG: AppPermissionCatalog[] = [
|
||||
section('settings', 'Настройки', 'SSO и системные настройки', ['admin']),
|
||||
],
|
||||
},
|
||||
{
|
||||
appId: 'cdn',
|
||||
title: 'CDN Manager',
|
||||
sections: [
|
||||
section('dashboard', 'Панель', 'KPI и обзор', ['read']),
|
||||
section('nodes', 'Ноды', 'Канонические хосты A/AAAA'),
|
||||
section('aliases', 'Алиасы', 'CNAME и retarget'),
|
||||
section('zones', 'Зоны', 'Cloudflare zones и BIND export'),
|
||||
section('sync', 'Синхронизация', 'Pull/diff/apply DNS', ['write']),
|
||||
section('topology', 'Топология', 'Схема флота', ['read']),
|
||||
section('settings', 'Настройки', 'Naming, TTL, Cloudflare', ['admin']),
|
||||
],
|
||||
},
|
||||
{
|
||||
appId: 'mm',
|
||||
title: 'MikrotikManager',
|
||||
sections: [
|
||||
section('dashboard', 'Дашборд', 'Обзор и KPI', ['read']),
|
||||
section('servers', 'Серверы', 'MikroTik / CHR хосты'),
|
||||
section('filters', 'Фильтры', 'Firewall и address-list'),
|
||||
section('bgp', 'BGP', 'BGP-сессии и префиксы'),
|
||||
section('uptime', 'Uptime', 'Пинги и speed-probes'),
|
||||
section('traffic', 'Трафик', 'Интерфейсы и графики'),
|
||||
section('alerts', 'Алерты', 'Правила и Telegram'),
|
||||
section('backups', 'Бэкапы', 'Конфиги и расписание'),
|
||||
section('certificates', 'Сертификаты', 'ACME / TLS'),
|
||||
section('network', 'Сеть', 'GRE, OSPF, пути'),
|
||||
section('settings', 'Настройки', 'Настройки приложения', ['admin']),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function permissionKey(
|
||||
@@ -400,11 +444,42 @@ export const adminUserSchema = z.object({
|
||||
permissions: z.array(z.string()),
|
||||
last_login_at: z.string().nullable(),
|
||||
last_login_ip: z.string().nullable(),
|
||||
passkey_count: z.number().int().nonnegative().default(0),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
export type AdminUser = z.infer<typeof adminUserSchema>
|
||||
|
||||
export const passkeyCredentialSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created_at: z.string(),
|
||||
last_used_at: z.string().nullable(),
|
||||
device_type: z.string().nullable(),
|
||||
})
|
||||
export type PasskeyCredential = z.infer<typeof passkeyCredentialSchema>
|
||||
|
||||
export const webauthnOptionsResponseSchema = z.object({
|
||||
challenge_id: z.string(),
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
export type WebauthnOptionsResponse = z.infer<
|
||||
typeof webauthnOptionsResponseSchema
|
||||
>
|
||||
|
||||
export const webauthnVerifyRequestSchema = z.object({
|
||||
challenge_id: z.string().min(1),
|
||||
response: z.unknown(),
|
||||
return_to: z.string().url().optional(),
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
})
|
||||
export type WebauthnVerifyRequest = z.infer<typeof webauthnVerifyRequestSchema>
|
||||
|
||||
export const patchPasskeyRequestSchema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
})
|
||||
export type PatchPasskeyRequest = z.infer<typeof patchPasskeyRequestSchema>
|
||||
|
||||
export const adminSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
|
||||
Generated
+217
@@ -72,6 +72,9 @@ importers:
|
||||
'@node-rs/argon2':
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
'@simplewebauthn/server':
|
||||
specifier: ^13.3.2
|
||||
version: 13.3.2
|
||||
fastify:
|
||||
specifier: ^5.4.0
|
||||
version: 5.10.0
|
||||
@@ -127,6 +130,9 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.4.0
|
||||
version: 5.4.0([email protected]([email protected]))
|
||||
'@simplewebauthn/browser':
|
||||
specifier: ^13.3.0
|
||||
version: 13.3.0
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.3([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
|
||||
@@ -1250,6 +1256,9 @@ packages:
|
||||
'@floating-ui/[email protected]':
|
||||
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
||||
|
||||
'@hexagon/[email protected]':
|
||||
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
|
||||
|
||||
'@hookform/[email protected]':
|
||||
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
|
||||
peerDependencies:
|
||||
@@ -1298,6 +1307,9 @@ packages:
|
||||
'@keyv/[email protected]':
|
||||
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
|
||||
|
||||
'@levischuck/[email protected]':
|
||||
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
|
||||
|
||||
'@lukeed/[email protected]':
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1471,6 +1483,46 @@ packages:
|
||||
'@oxc-project/[email protected]':
|
||||
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-hiRJvr5ydif9fbTA7czZw1OfgzYDhu5gXNzhDfS3wSXzWYoSS/BHY+Wu2c36CNbn3mI6FoVLf0yHvQy0D4rZWw==}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-N3POfw5RA7efAliAATiudtmvKQqukVEOzrMQuqQY/us2EPKczMy2WiecLt1SX6s3b0OwcFaPUXGF6uIYlBUTbg==}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-E9zYmC5mk7eiDKqQAOsZGrJ7mUCIDC0031s4Nsl7dj1Za5EBKcHVqY+1vD/a4xbk480PGqvi455W2b4FeHRJ8Q==}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-4xmeZiZ46VI2qGbiZPzdv6p9IhMtjWdbwZf3VCgN8OAVcued60ej5Ki2FF94srVH4Ot/h45Co7T5C/fpQXP4rA==}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-Nzwoj+fRr1XB9CQuc4AanUuvQ3OIXAY+ngZIYP+eZUqd+Sonj+ZHTnrg+egQuUJ64/iMi9HFPN04MokGrFTK0w==}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-ecGZpkY6Lq5bSgTFU+LS74WjawzDgjWHcjFMVNPH/1C0j53Xi9dmLcPMdmZyk8gdsd2RKeV1fP9EbLB6W6zZ1g==}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-yjWVrQEmPp2y9lWjLLE28BRHbt7wYdwWvwXjFgNuekP9mDD/ofz31muVI8qOg2as7XZs1bZIZGPPnJ/0osTClQ==}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-t7m3e9p/Gf9YoMM+hLsHUqB+NhOlifiOkENAIG4RV2BFWVGTATVzbXoR8L0EgNWpiriPaeIjBCS5B9PTtkaxQw==}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-SOux4+jikCnOwoJvpBp/grOqzFmJPnNSwe3sAg1Bn93YdmCiDtvolZifJIhiRq0UWMTnLRPj9/ZCMAc2W9VsMQ==}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-v5Oa6p7hCT3ONqYHQyTFQYcycD06eMhHbr0m7evpvPQSpWJIq8GgbdnvA9bVGTUlOx6KOeDrX7Z0W4s/yboNTg==}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-HE+ejy9dX9JP3yLL6CGYoWym4Cted1lGXH7DxsbNDGiq8KabwVR7mzYidwsyNZ/m0hNWjiS+dzSwrsgMB/OIaQ==}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@pinojs/[email protected]':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
@@ -1754,6 +1806,13 @@ packages:
|
||||
resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@simplewebauthn/[email protected]':
|
||||
resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==}
|
||||
|
||||
'@simplewebauthn/[email protected]':
|
||||
resolution: {integrity: sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@sindresorhus/[email protected]':
|
||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2258,6 +2317,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4094,6 +4157,13 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@@ -4171,6 +4241,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -4572,6 +4645,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -4599,6 +4675,10 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
|
||||
@@ -5774,6 +5854,8 @@ snapshots:
|
||||
|
||||
'@floating-ui/[email protected]': {}
|
||||
|
||||
'@hexagon/[email protected]': {}
|
||||
|
||||
'@hookform/[email protected]([email protected]([email protected]))':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
@@ -5818,6 +5900,8 @@ snapshots:
|
||||
|
||||
'@keyv/[email protected]': {}
|
||||
|
||||
'@levischuck/[email protected]': {}
|
||||
|
||||
'@lukeed/[email protected]': {}
|
||||
|
||||
'@markwylde/[email protected]':
|
||||
@@ -5990,6 +6074,106 @@ snapshots:
|
||||
|
||||
'@oxc-project/[email protected]': {}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.9.3
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.9.3
|
||||
'@peculiar/asn1-x509': 2.9.3
|
||||
'@peculiar/asn1-x509-attr': 2.9.3
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.9.3
|
||||
'@peculiar/asn1-x509': 2.9.3
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.9.3
|
||||
'@peculiar/asn1-x509': 2.9.3
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.9.3
|
||||
'@peculiar/asn1-pkcs8': 2.9.3
|
||||
'@peculiar/asn1-rsa': 2.9.3
|
||||
'@peculiar/asn1-schema': 2.9.3
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.9.3
|
||||
'@peculiar/asn1-x509': 2.9.3
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.9.3
|
||||
'@peculiar/asn1-pfx': 2.9.3
|
||||
'@peculiar/asn1-pkcs8': 2.9.3
|
||||
'@peculiar/asn1-schema': 2.9.3
|
||||
'@peculiar/asn1-x509': 2.9.3
|
||||
'@peculiar/asn1-x509-attr': 2.9.3
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.9.3
|
||||
'@peculiar/asn1-x509': 2.9.3
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
'@peculiar/utils': 2.0.3
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.9.3
|
||||
'@peculiar/asn1-x509': 2.9.3
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
'@peculiar/asn1-schema': 2.9.3
|
||||
'@peculiar/utils': 2.0.3
|
||||
asn1js: 3.0.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
'@peculiar/asn1-cms': 2.9.3
|
||||
'@peculiar/asn1-csr': 2.9.3
|
||||
'@peculiar/asn1-ecc': 2.9.3
|
||||
'@peculiar/asn1-pkcs9': 2.9.3
|
||||
'@peculiar/asn1-rsa': 2.9.3
|
||||
'@peculiar/asn1-schema': 2.9.3
|
||||
'@peculiar/asn1-x509': 2.9.3
|
||||
pvtsutils: 1.3.6
|
||||
reflect-metadata: 0.2.2
|
||||
tslib: 2.8.1
|
||||
tsyringe: 4.10.0
|
||||
|
||||
'@pinojs/[email protected]': {}
|
||||
|
||||
'@pnpm/[email protected]': {}
|
||||
@@ -6229,6 +6413,19 @@ snapshots:
|
||||
|
||||
'@simple-libs/[email protected]': {}
|
||||
|
||||
'@simplewebauthn/[email protected]': {}
|
||||
|
||||
'@simplewebauthn/[email protected]':
|
||||
dependencies:
|
||||
'@hexagon/base64': 1.1.28
|
||||
'@levischuck/tiny-cbor': 0.2.11
|
||||
'@peculiar/asn1-android': 2.9.3
|
||||
'@peculiar/asn1-ecc': 2.9.3
|
||||
'@peculiar/asn1-rsa': 2.9.3
|
||||
'@peculiar/asn1-schema': 2.9.3
|
||||
'@peculiar/asn1-x509': 2.9.3
|
||||
'@peculiar/x509': 1.14.3
|
||||
|
||||
'@sindresorhus/[email protected]': {}
|
||||
|
||||
'@sindresorhus/[email protected]': {}
|
||||
@@ -6786,6 +6983,12 @@ snapshots:
|
||||
minimalistic-assert: 1.0.1
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
pvtsutils: 1.3.6
|
||||
pvutils: 1.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -8453,6 +8656,12 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -8537,6 +8746,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@pnpm/npm-conf': 3.0.3
|
||||
@@ -8955,6 +9166,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected])([email protected])([email protected])([email protected]):
|
||||
@@ -8991,6 +9204,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
tslib: 1.14.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
Reference in New Issue
Block a user