feat: add groups management with CRUD operations and policy target assignment

Add Group type with id, name, description, members array and optional user_ids field. Add name field to policy targets for display. Add groups API client methods for list, create, update and delete operations. Add GroupsPage component with create form, edit modal, member selection and table view. Add groups route and navigation item to Layout. Add reusable Modal component with title, subtitle and close handler. Update
This commit is contained in:
2026-03-17 21:42:46 +01:00
parent 0986a36aca
commit a8fbe725a2
17 changed files with 900 additions and 103 deletions

View File

@@ -56,9 +56,22 @@ export type Policy = {
targets?: Array<{
type: string;
id: string;
name?: string;
}>;
};
export type Group = {
id: string;
name: string;
description: string;
members: Array<{
id: string;
username: string;
display_name: string;
}>;
user_ids?: string[];
};
export type Gateway = {
id: string;
name: string;
@@ -129,6 +142,29 @@ export const api = {
request<{ ok: boolean }>(`/admin/users/${userId}`, {
method: "DELETE"
}),
groups: () => request<Group[]>("/admin/groups"),
createGroup: (payload: {
name: string;
description: string;
user_ids: string[];
}) =>
request<Group>("/admin/groups", {
method: "POST",
body: JSON.stringify(payload)
}),
updateGroup: (groupId: string, payload: {
name?: string;
description?: string;
user_ids?: string[];
}) =>
request<Group>(`/admin/groups/${groupId}`, {
method: "PATCH",
body: JSON.stringify(payload)
}),
deleteGroup: (groupId: string) =>
request<{ ok: boolean }>(`/admin/groups/${groupId}`, {
method: "DELETE"
}),
devices: () => request<Device[]>("/admin/devices"),
deviceProfile: (deviceId: string) => request<DeviceProfile>(`/admin/devices/${deviceId}/profile`),
revokeDevice: (deviceId: string) =>

View File

@@ -8,6 +8,7 @@ import { LoginPage } from "../features/auth/LoginPage";
import { DashboardPage } from "../features/dashboard/DashboardPage";
import { DevicesPage } from "../features/devices/DevicesPage";
import { GatewaysPage } from "../features/gateways/GatewaysPage";
import { GroupsPage } from "../features/groups/GroupsPage";
import { PoliciesPage } from "../features/policies/PoliciesPage";
import { SettingsPage } from "../features/settings/SettingsPage";
import { UsersPage } from "../features/users/UsersPage";
@@ -41,6 +42,7 @@ export function App() {
<Route element={authenticated ? <Layout onLogout={handleLogout} /> : <Navigate to="/login" replace />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/devices" element={<DevicesPage />} />
<Route path="/policies" element={<PoliciesPage />} />
<Route path="/gateways" element={<GatewaysPage />} />

View File

@@ -3,6 +3,7 @@ import { NavLink, Outlet } from "react-router-dom";
const items = [
["Dashboard", "/"],
["Users", "/users"],
["Groups", "/groups"],
["Devices", "/devices"],
["Policies", "/policies"],
["Gateways", "/gateways"],

View File

@@ -0,0 +1,26 @@
import { PropsWithChildren } from "react";
type ModalProps = PropsWithChildren<{
title: string;
subtitle?: string;
onClose: () => void;
}>;
export function Modal({ title, subtitle, onClose, children }: ModalProps) {
return (
<div className="modal-backdrop" onClick={onClose} role="presentation">
<div className="modal-card" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={title}>
<div className="modal-header">
<div>
<h3>{title}</h3>
{subtitle ? <p>{subtitle}</p> : null}
</div>
<button className="ghost-button" type="button" onClick={onClose}>
Close
</button>
</div>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../api/client";
import { Modal } from "../../components/Modal";
import { Page } from "../../components/Page";
import { Table } from "../../components/Table";
const columns = [
{ key: "name", label: "Group" },
{ key: "description", label: "Description" },
{ key: "members", label: "Members" },
{ key: "actions", label: "Actions" }
];
export function GroupsPage() {
const queryClient = useQueryClient();
const groupsQuery = useQuery({
queryKey: ["groups"],
queryFn: api.groups
});
const usersQuery = useQuery({
queryKey: ["users"],
queryFn: api.users
});
const [form, setForm] = useState({
name: "",
description: "",
user_ids: [] as string[]
});
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({
name: "",
description: "",
user_ids: [] as string[]
});
const createMutation = useMutation({
mutationFn: api.createGroup,
onSuccess: () => {
setForm({ name: "", description: "", user_ids: [] });
void queryClient.invalidateQueries({ queryKey: ["groups"] });
}
});
const updateMutation = useMutation({
mutationFn: ({ groupId, payload }: { groupId: string; payload: typeof editForm }) => api.updateGroup(groupId, payload),
onSuccess: () => {
setEditingGroupId(null);
setEditForm({ name: "", description: "", user_ids: [] });
void queryClient.invalidateQueries({ queryKey: ["groups"] });
}
});
const deleteMutation = useMutation({
mutationFn: api.deleteGroup,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["groups"] });
}
});
const rows = groupsQuery.data?.map((group) => ({
id: group.id,
name: group.name,
description: group.description || "-",
members: group.members.length ? group.members.map((member) => member.username).join(", ") : "No members"
})) ?? [];
function toggleUser(userId: string, editing = false) {
if (editing) {
setEditForm((value) => ({
...value,
user_ids: value.user_ids.includes(userId)
? value.user_ids.filter((id) => id !== userId)
: [...value.user_ids, userId]
}));
return;
}
setForm((value) => ({
...value,
user_ids: value.user_ids.includes(userId)
? value.user_ids.filter((id) => id !== userId)
: [...value.user_ids, userId]
}));
}
function onSubmit(event: FormEvent) {
event.preventDefault();
createMutation.mutate(form);
}
function startEdit(groupId: string) {
const group = groupsQuery.data?.find((item) => item.id === groupId);
if (!group) {
return;
}
setEditingGroupId(groupId);
setEditForm({
name: group.name,
description: group.description,
user_ids: group.members.map((member) => member.id)
});
}
function onEditSubmit(event: FormEvent) {
event.preventDefault();
if (!editingGroupId) {
return;
}
updateMutation.mutate({
groupId: editingGroupId,
payload: editForm
});
}
return (
<Page
title="Groups"
subtitle="Bundle users into reusable access groups for policy assignment."
actions={<span className="pill">Reusable targets</span>}
>
<form className="stacked-form card" onSubmit={onSubmit}>
<div className="form-grid two">
<input placeholder="group name" value={form.name} onChange={(event) => setForm((value) => ({ ...value, name: event.target.value }))} />
<input placeholder="description" value={form.description} onChange={(event) => setForm((value) => ({ ...value, description: event.target.value }))} />
</div>
<div className="selection-panel">
<p className="eyebrow">Members</p>
<div className="selection-list">
{(usersQuery.data ?? []).map((user) => (
<label className="selection-item" key={user.id}>
<input type="checkbox" checked={form.user_ids.includes(user.id)} onChange={() => toggleUser(user.id)} />
<span>{user.username}</span>
</label>
))}
</div>
</div>
<div className="action-row">
<button className="button" type="submit" disabled={createMutation.isPending}>Create group</button>
</div>
</form>
{groupsQuery.isError ? <p className="notice">Unable to load groups from the API.</p> : null}
{createMutation.isError ? <p className="notice">Unable to create group.</p> : null}
{updateMutation.isError ? <p className="notice">Unable to update group.</p> : null}
{deleteMutation.isError ? <p className="notice">Unable to delete group.</p> : null}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => {
if (column.key === "actions") {
return (
<div className="action-row">
<button className="ghost-button" type="button" onClick={() => startEdit(row.id)}>Edit</button>
<button className="ghost-button" type="button" onClick={() => deleteMutation.mutate(row.id)}>Delete</button>
</div>
);
}
return <span>{row[column.key as keyof (typeof rows)[number]]}</span>;
}}
/>
{editingGroupId ? (
<Modal title="Edit group" subtitle="Adjust members and details in one place." onClose={() => setEditingGroupId(null)}>
<form className="stacked-form" onSubmit={onEditSubmit}>
<input placeholder="group name" value={editForm.name} onChange={(event) => setEditForm((value) => ({ ...value, name: event.target.value }))} />
<input placeholder="description" value={editForm.description} onChange={(event) => setEditForm((value) => ({ ...value, description: event.target.value }))} />
<div className="selection-panel">
<p className="eyebrow">Members</p>
<div className="selection-list">
{(usersQuery.data ?? []).map((user) => (
<label className="selection-item" key={user.id}>
<input type="checkbox" checked={editForm.user_ids.includes(user.id)} onChange={() => toggleUser(user.id, true)} />
<span>{user.username}</span>
</label>
))}
</div>
</div>
<div className="action-row">
<button className="button" type="submit" disabled={updateMutation.isPending}>Save group</button>
<button className="ghost-button" type="button" onClick={() => setEditingGroupId(null)}>Cancel</button>
</div>
</form>
</Modal>
) : null}
</Page>
);
}

View File

@@ -2,6 +2,7 @@ import { FormEvent, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../api/client";
import { Modal } from "../../components/Modal";
import { Page } from "../../components/Page";
import { Table } from "../../components/Table";
@@ -23,36 +24,45 @@ export function PoliciesPage() {
queryKey: ["users"],
queryFn: api.users
});
const groupsQuery = useQuery({
queryKey: ["groups"],
queryFn: api.groups
});
const [form, setForm] = useState({
name: "",
description: "",
destinations: "",
targetUserId: "",
targetType: "user",
targetIds: [] as string[],
fullTunnel: false
});
const [createOpen, setCreateOpen] = useState(false);
const [editingPolicyId, setEditingPolicyId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({
name: "",
description: "",
destinations: "",
fullTunnel: false,
isActive: true
isActive: true,
targetType: "user",
targetIds: [] as string[]
});
const createMutation = useMutation({
mutationFn: api.createPolicy,
onSuccess: () => {
setForm({ name: "", description: "", destinations: "", targetUserId: "", fullTunnel: false });
setCreateOpen(false);
setForm({ name: "", description: "", destinations: "", targetType: "user", targetIds: [], fullTunnel: false });
void queryClient.invalidateQueries({ queryKey: ["policies"] });
}
});
const updateMutation = useMutation({
mutationFn: ({ policyId, payload }: { policyId: string; payload: { name: string; description: string; destinations: string[]; full_tunnel: boolean; is_active: boolean } }) =>
mutationFn: ({ policyId, payload }: { policyId: string; payload: { name: string; description: string; destinations: string[]; full_tunnel: boolean; is_active: boolean; targets: Array<{ type: string; id: string }> } }) =>
api.updatePolicy(policyId, payload),
onSuccess: () => {
setEditingPolicyId(null);
setEditForm({ name: "", description: "", destinations: "", fullTunnel: false, isActive: true });
setEditForm({ name: "", description: "", destinations: "", fullTunnel: false, isActive: true, targetType: "user", targetIds: [] });
void queryClient.invalidateQueries({ queryKey: ["policies"] });
}
});
@@ -67,16 +77,19 @@ export function PoliciesPage() {
const rows = query.data?.map((policy) => ({
id: policy.id,
name: policy.name,
targets: policy.targets?.length ? `${policy.targets.length} target(s)` : "assigned targets",
targets: policy.targets?.length ? policy.targets.map((target) => `${target.type}: ${target.name ?? target.id}`).join(", ") : "No targets",
destinations: policy.destinations?.join(", ") ?? (policy.full_tunnel ? "0.0.0.0/0" : "-"),
mode: policy.full_tunnel ? "Full tunnel" : "Split tunnel"
})) ?? [];
const selectableUsers = useMemo(() => usersQuery.data ?? [], [usersQuery.data]);
const selectableGroups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const selectableTargets = form.targetType === "group" ? selectableGroups : selectableUsers;
const editableTargets = editForm.targetType === "group" ? selectableGroups : selectableUsers;
function onSubmit(event: FormEvent) {
event.preventDefault();
if (!form.targetUserId) {
if (!form.targetIds.length) {
return;
}
createMutation.mutate({
@@ -86,7 +99,7 @@ export function PoliciesPage() {
effect: "allow",
full_tunnel: form.fullTunnel,
destinations: form.fullTunnel ? ["0.0.0.0/0"] : form.destinations.split(",").map((value) => value.trim()).filter(Boolean),
targets: [{ type: "user", id: form.targetUserId }]
targets: form.targetIds.map((id) => ({ type: form.targetType, id }))
});
}
@@ -96,12 +109,15 @@ export function PoliciesPage() {
return;
}
setEditingPolicyId(policyId);
const targetType = policy.targets?.[0]?.type ?? "user";
setEditForm({
name: policy.name,
description: policy.description,
destinations: policy.destinations?.join(", ") ?? "",
fullTunnel: policy.full_tunnel,
isActive: policy.is_active
isActive: policy.is_active,
targetType,
targetIds: policy.targets?.filter((target) => target.type === targetType).map((target) => target.id) ?? []
});
}
@@ -117,68 +133,37 @@ export function PoliciesPage() {
description: editForm.description,
destinations: editForm.fullTunnel ? ["0.0.0.0/0"] : editForm.destinations.split(",").map((value) => value.trim()).filter(Boolean),
full_tunnel: editForm.fullTunnel,
is_active: editForm.isActive
is_active: editForm.isActive,
targets: editForm.targetIds.map((id) => ({ type: editForm.targetType, id }))
}
});
}
function toggleTarget(id: string, editing = false) {
const setter = editing ? setEditForm : setForm;
setter((value) => ({
...value,
targetIds: value.targetIds.includes(id)
? value.targetIds.filter((item) => item !== id)
: [...value.targetIds, id]
}));
}
return (
<Page
title="Policies"
subtitle="Manage per-user and per-device access controls enforced at the gateway."
actions={<span className="pill">Gateway enforced</span>}
actions={(
<div className="action-row">
<span className="pill">Gateway enforced</span>
<button className="button" type="button" onClick={() => setCreateOpen(true)}>New policy</button>
</div>
)}
>
<form className="inline-form" onSubmit={onSubmit}>
<input placeholder="policy name" value={form.name} onChange={(event) => setForm((value) => ({ ...value, name: event.target.value }))} />
<input placeholder="description" value={form.description} onChange={(event) => setForm((value) => ({ ...value, description: event.target.value }))} />
<input
placeholder="destinations: 172.16.10.0/24,10.0.0.5/32"
value={form.destinations}
onChange={(event) => setForm((value) => ({ ...value, destinations: event.target.value }))}
disabled={form.fullTunnel}
/>
<select value={form.targetUserId} onChange={(event) => setForm((value) => ({ ...value, targetUserId: event.target.value }))}>
<option value="">target user</option>
{selectableUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
<label className="checkbox">
<input type="checkbox" checked={form.fullTunnel} onChange={(event) => setForm((value) => ({ ...value, fullTunnel: event.target.checked }))} />
Full tunnel
</label>
<button className="button" type="submit" disabled={createMutation.isPending}>Create policy</button>
</form>
{query.isError ? <p className="notice">Unable to load policies from the API.</p> : null}
{createMutation.isError ? <p className="notice">Unable to create policy.</p> : null}
{updateMutation.isError ? <p className="notice">Unable to update policy.</p> : null}
{deleteMutation.isError ? <p className="notice">Unable to delete policy.</p> : null}
{editingPolicyId ? (
<form className="inline-form" onSubmit={onEditSubmit}>
<input placeholder="policy name" value={editForm.name} onChange={(event) => setEditForm((value) => ({ ...value, name: event.target.value }))} />
<input placeholder="description" value={editForm.description} onChange={(event) => setEditForm((value) => ({ ...value, description: event.target.value }))} />
<input
placeholder="destinations"
value={editForm.destinations}
onChange={(event) => setEditForm((value) => ({ ...value, destinations: event.target.value }))}
disabled={editForm.fullTunnel}
/>
<label className="checkbox">
<input type="checkbox" checked={editForm.fullTunnel} onChange={(event) => setEditForm((value) => ({ ...value, fullTunnel: event.target.checked }))} />
Full tunnel
</label>
<label className="checkbox">
<input type="checkbox" checked={editForm.isActive} onChange={(event) => setEditForm((value) => ({ ...value, isActive: event.target.checked }))} />
Active
</label>
<div className="action-row">
<button className="button" type="submit" disabled={updateMutation.isPending}>Save policy</button>
<button className="ghost-button" type="button" onClick={() => setEditingPolicyId(null)}>Cancel</button>
</div>
</form>
) : null}
<Table
columns={columns}
rows={rows}
@@ -194,6 +179,84 @@ export function PoliciesPage() {
return <span>{row[column.key as keyof (typeof rows)[number]]}</span>;
}}
/>
{createOpen ? (
<Modal title="Create policy" subtitle="Choose destinations and assign them to users or groups." onClose={() => setCreateOpen(false)}>
<form className="stacked-form" onSubmit={onSubmit}>
<input placeholder="policy name" value={form.name} onChange={(event) => setForm((value) => ({ ...value, name: event.target.value }))} />
<input placeholder="description" value={form.description} onChange={(event) => setForm((value) => ({ ...value, description: event.target.value }))} />
<select value={form.targetType} onChange={(event) => setForm((value) => ({ ...value, targetType: event.target.value, targetIds: [] }))}>
<option value="user">Users</option>
<option value="group">Groups</option>
</select>
<div className="selection-panel">
<p className="eyebrow">{form.targetType === "group" ? "Target groups" : "Target users"}</p>
<div className="selection-list">
{selectableTargets.map((target) => (
<label className="selection-item" key={target.id}>
<input type="checkbox" checked={form.targetIds.includes(target.id)} onChange={() => toggleTarget(target.id)} />
<span>{"username" in target ? target.username : target.name}</span>
</label>
))}
</div>
</div>
<input
placeholder="destinations: 172.16.10.0/24,10.0.0.5/32"
value={form.destinations}
onChange={(event) => setForm((value) => ({ ...value, destinations: event.target.value }))}
disabled={form.fullTunnel}
/>
<label className="checkbox">
<input type="checkbox" checked={form.fullTunnel} onChange={(event) => setForm((value) => ({ ...value, fullTunnel: event.target.checked }))} />
Full tunnel
</label>
<div className="action-row">
<button className="button" type="submit" disabled={createMutation.isPending}>Create policy</button>
<button className="ghost-button" type="button" onClick={() => setCreateOpen(false)}>Cancel</button>
</div>
</form>
</Modal>
) : null}
{editingPolicyId ? (
<Modal title="Edit policy" subtitle="Change targets, mode, and destinations in one place." onClose={() => setEditingPolicyId(null)}>
<form className="stacked-form" onSubmit={onEditSubmit}>
<input placeholder="policy name" value={editForm.name} onChange={(event) => setEditForm((value) => ({ ...value, name: event.target.value }))} />
<input placeholder="description" value={editForm.description} onChange={(event) => setEditForm((value) => ({ ...value, description: event.target.value }))} />
<select value={editForm.targetType} onChange={(event) => setEditForm((value) => ({ ...value, targetType: event.target.value, targetIds: [] }))}>
<option value="user">Users</option>
<option value="group">Groups</option>
</select>
<div className="selection-panel">
<p className="eyebrow">{editForm.targetType === "group" ? "Target groups" : "Target users"}</p>
<div className="selection-list">
{editableTargets.map((target) => (
<label className="selection-item" key={target.id}>
<input type="checkbox" checked={editForm.targetIds.includes(target.id)} onChange={() => toggleTarget(target.id, true)} />
<span>{"username" in target ? target.username : target.name}</span>
</label>
))}
</div>
</div>
<input
placeholder="destinations"
value={editForm.destinations}
onChange={(event) => setEditForm((value) => ({ ...value, destinations: event.target.value }))}
disabled={editForm.fullTunnel}
/>
<label className="checkbox">
<input type="checkbox" checked={editForm.fullTunnel} onChange={(event) => setEditForm((value) => ({ ...value, fullTunnel: event.target.checked }))} />
Full tunnel
</label>
<label className="checkbox">
<input type="checkbox" checked={editForm.isActive} onChange={(event) => setEditForm((value) => ({ ...value, isActive: event.target.checked }))} />
Active
</label>
<div className="action-row">
<button className="button" type="submit" disabled={updateMutation.isPending}>Save policy</button>
<button className="ghost-button" type="button" onClick={() => setEditingPolicyId(null)}>Cancel</button>
</div>
</form>
</Modal>
) : null}
</Page>
);
}

View File

@@ -2,6 +2,7 @@ import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../api/client";
import { Modal } from "../../components/Modal";
import { Page } from "../../components/Page";
import { Table } from "../../components/Table";
@@ -121,25 +122,6 @@ export function UsersPage() {
{createMutation.isError ? <p className="notice">Unable to create user.</p> : null}
{updateMutation.isError ? <p className="notice">Unable to update user.</p> : null}
{deleteMutation.isError ? <p className="notice">Unable to delete user.</p> : null}
{editingUserId ? (
<form className="inline-form" onSubmit={onEditSubmit}>
<input placeholder="display name" value={editForm.display_name} onChange={(event) => setEditForm((value) => ({ ...value, display_name: event.target.value }))} />
<input placeholder="email" value={editForm.email} onChange={(event) => setEditForm((value) => ({ ...value, email: event.target.value }))} />
<select value={editForm.role} onChange={(event) => setEditForm((value) => ({ ...value, role: event.target.value }))}>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
<input placeholder="new password (optional)" type="password" value={editForm.password} onChange={(event) => setEditForm((value) => ({ ...value, password: event.target.value }))} />
<label className="checkbox">
<input type="checkbox" checked={editForm.is_active} onChange={(event) => setEditForm((value) => ({ ...value, is_active: event.target.checked }))} />
Active
</label>
<div className="action-row">
<button className="button" type="submit" disabled={updateMutation.isPending}>Save user</button>
<button className="ghost-button" type="button" onClick={() => setEditingUserId(null)}>Cancel</button>
</div>
</form>
) : null}
<Table
columns={columns}
rows={rows}
@@ -155,6 +137,27 @@ export function UsersPage() {
return <span>{row[column.key as keyof (typeof rows)[number]]}</span>;
}}
/>
{editingUserId ? (
<Modal title="Edit user" subtitle="Update role, contact details, and account state." onClose={() => setEditingUserId(null)}>
<form className="stacked-form" onSubmit={onEditSubmit}>
<input placeholder="display name" value={editForm.display_name} onChange={(event) => setEditForm((value) => ({ ...value, display_name: event.target.value }))} />
<input placeholder="email" value={editForm.email} onChange={(event) => setEditForm((value) => ({ ...value, email: event.target.value }))} />
<select value={editForm.role} onChange={(event) => setEditForm((value) => ({ ...value, role: event.target.value }))}>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
<input placeholder="new password (optional)" type="password" value={editForm.password} onChange={(event) => setEditForm((value) => ({ ...value, password: event.target.value }))} />
<label className="checkbox">
<input type="checkbox" checked={editForm.is_active} onChange={(event) => setEditForm((value) => ({ ...value, is_active: event.target.checked }))} />
Active
</label>
<div className="action-row">
<button className="button" type="submit" disabled={updateMutation.isPending}>Save user</button>
<button className="ghost-button" type="button" onClick={() => setEditingUserId(null)}>Cancel</button>
</div>
</form>
</Modal>
) : null}
</Page>
);
}

View File

@@ -230,6 +230,30 @@ button {
align-items: center;
}
.stacked-form {
display: grid;
gap: 14px;
}
.stacked-form input,
.stacked-form select {
width: 100%;
border: 1px solid var(--line);
background: rgba(8, 14, 26, 0.86);
color: var(--text);
border-radius: 14px;
padding: 12px 14px;
}
.form-grid {
display: grid;
gap: 12px;
}
.form-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.inline-form input,
.inline-form select {
width: 100%;
@@ -309,6 +333,60 @@ button {
gap: 12px;
}
.selection-panel {
display: grid;
gap: 10px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(8, 14, 26, 0.56);
}
.selection-list {
display: grid;
gap: 10px;
max-height: 240px;
overflow: auto;
}
.selection-item {
display: flex;
align-items: center;
gap: 10px;
color: var(--text);
}
.modal-backdrop {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgba(2, 6, 15, 0.72);
backdrop-filter: blur(10px);
z-index: 50;
}
.modal-card {
width: min(640px, 100%);
max-height: calc(100vh - 48px);
overflow: auto;
display: grid;
gap: 18px;
padding: 24px;
background: #111b30;
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: var(--shadow);
}
.modal-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
@media (max-width: 960px) {
.shell {
grid-template-columns: 1fr;