feat(auth): manage vehicle grant intervals
This commit is contained in:
@@ -37,6 +37,7 @@ export interface AdminUser {
|
||||
menuKeys: string[];
|
||||
vehicleVins: string[];
|
||||
vehicles: AdminVehicleGrant[];
|
||||
grantHistory: AdminVehicleGrantHistory[];
|
||||
lastLoginAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -45,6 +46,23 @@ export interface AdminUser {
|
||||
export interface AdminVehicleGrant {
|
||||
vin: string;
|
||||
plate: string;
|
||||
validFrom: string;
|
||||
validTo?: string;
|
||||
sourceSystem: string;
|
||||
grantedBy: string;
|
||||
}
|
||||
|
||||
export interface AdminVehicleGrantHistory extends AdminVehicleGrant {
|
||||
id: number;
|
||||
revokedBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CustomerVehicleGrantInput {
|
||||
vin: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
}
|
||||
|
||||
export interface CustomerUserInput {
|
||||
@@ -55,7 +73,8 @@ export interface CustomerUserInput {
|
||||
customerRef: string;
|
||||
tenantRef: string;
|
||||
menuKeys: string[];
|
||||
vehicleVins: string[];
|
||||
vehicleVins?: string[];
|
||||
vehicleGrants: CustomerVehicleGrantInput[];
|
||||
}
|
||||
|
||||
export interface MonitorSummary {
|
||||
|
||||
@@ -29,7 +29,8 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
|
||||
authProvider: 'local',
|
||||
menuKeys: ['monitor'],
|
||||
vehicleVins: ['VIN001'],
|
||||
vehicles: [{ vin: 'VIN001', plate: '粤A11111' }],
|
||||
vehicles: [{ vin: 'VIN001', plate: '粤A11111', validFrom: '2026-06-05T00:00:00+08:00', sourceSystem: 'manual', grantedBy: '平台管理员' }],
|
||||
grantHistory: [{ id: 1, vin: 'VIN001', plate: '粤A11111', validFrom: '2026-06-05T00:00:00+08:00', sourceSystem: 'manual', grantedBy: '平台管理员', revokedBy: '', createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z' }],
|
||||
createdAt: '2026-07-16T00:00:00Z',
|
||||
updatedAt: '2026-07-16T00:00:00Z'
|
||||
}]);
|
||||
@@ -40,7 +41,7 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
|
||||
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /华东客户/ }));
|
||||
expect(await screen.findByRole('button', { name: '移除 粤A11111' })).toHaveTextContent('粤A11111VIN001');
|
||||
expect((await screen.findByRole('button', { name: '移除 粤A11111' })).closest('article')).toHaveTextContent('粤A11111VIN001');
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '按车牌或 VIN 搜索' }), { target: { value: '粤A22222' } });
|
||||
await waitFor(() => expect(mocks.vehicleCoverage).toHaveBeenCalled());
|
||||
@@ -49,5 +50,25 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
|
||||
expect((mocks.vehicleCoverage.mock.calls[0][0] as URLSearchParams).get('keyword')).toBe('粤A22222');
|
||||
|
||||
fireEvent.click(candidates[0]);
|
||||
expect(await screen.findByRole('button', { name: '移除 粤A22222' })).toHaveTextContent('粤A22222VIN002');
|
||||
expect((await screen.findByRole('button', { name: '移除 粤A22222' })).closest('article')).toHaveTextContent('粤A22222VIN002');
|
||||
});
|
||||
|
||||
test('submits per-vehicle authorization interval instead of only a VIN list', async () => {
|
||||
mocks.adminUsers.mockResolvedValue([{
|
||||
id: 7, username: 'customer-east', displayName: '华东客户', userType: 'customer', status: 'enabled',
|
||||
customerRef: '', tenantRef: '', authProvider: 'local', menuKeys: ['statistics'], vehicleVins: ['VIN001'],
|
||||
vehicles: [{ vin: 'VIN001', plate: '粤A11111', validFrom: '2026-07-16T14:08:18+08:00', sourceSystem: 'manual', grantedBy: '平台管理员' }],
|
||||
grantHistory: [], createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z'
|
||||
}]);
|
||||
mocks.updateCustomerUser.mockResolvedValue({ id: 7 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /华东客户/ }));
|
||||
fireEvent.change(await screen.findByLabelText('粤A11111 启用时间'), { target: { value: '2026-06-05T00:00' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存权限' }));
|
||||
await waitFor(() => expect(mocks.updateCustomerUser).toHaveBeenCalled());
|
||||
expect(mocks.updateCustomerUser.mock.calls[0][1]).toMatchObject({
|
||||
vehicleVins: ['VIN001'],
|
||||
vehicleGrants: [{ vin: 'VIN001', validFrom: '2026-06-05T00:00', validTo: '' }]
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { AdminUser, CustomerUserInput } from '../../api/types';
|
||||
import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types';
|
||||
|
||||
const customerMenus = [
|
||||
{ key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' },
|
||||
@@ -18,14 +18,29 @@ type Draft = {
|
||||
customerRef: string;
|
||||
tenantRef: string;
|
||||
menuKeys: string[];
|
||||
vehicleVins: string[];
|
||||
vehicleGrants: CustomerVehicleGrantInput[];
|
||||
};
|
||||
|
||||
const emptyDraft: Draft = { username: '', displayName: '', password: '', status: 'enabled', customerRef: '', tenantRef: '', menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'], vehicleVins: [] };
|
||||
const shanghaiDateTimeFormatter = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false
|
||||
});
|
||||
|
||||
function dateTimeInput(value?: string) {
|
||||
if (!value) return '';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return '';
|
||||
return shanghaiDateTimeFormatter.format(parsed).replace(' ', 'T');
|
||||
}
|
||||
|
||||
const emptyDraft: Draft = { username: '', displayName: '', password: '', status: 'enabled', customerRef: '', tenantRef: '', menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'], vehicleGrants: [] };
|
||||
|
||||
function draftFromUser(user?: AdminUser): Draft {
|
||||
if (!user) return { ...emptyDraft, menuKeys: [...emptyDraft.menuKeys], vehicleVins: [] };
|
||||
return { username: user.username, displayName: user.displayName, password: '', status: user.status, customerRef: user.customerRef, tenantRef: user.tenantRef, menuKeys: [...user.menuKeys], vehicleVins: [...user.vehicleVins] };
|
||||
if (!user) return { ...emptyDraft, menuKeys: [...emptyDraft.menuKeys], vehicleGrants: [] };
|
||||
return {
|
||||
username: user.username, displayName: user.displayName, password: '', status: user.status,
|
||||
customerRef: user.customerRef, tenantRef: user.tenantRef, menuKeys: [...user.menuKeys],
|
||||
vehicleGrants: user.vehicles.map((vehicle) => ({ vin: vehicle.vin, validFrom: dateTimeInput(vehicle.validFrom), validTo: dateTimeInput(vehicle.validTo) }))
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
@@ -71,7 +86,7 @@ export default function UsersPage() {
|
||||
enabled: deferredVehicleKeyword.length >= 1,
|
||||
staleTime: 30_000
|
||||
});
|
||||
const assigned = useMemo(() => new Set(draft.vehicleVins), [draft.vehicleVins]);
|
||||
const assigned = useMemo(() => new Set(draft.vehicleGrants.map((grant) => grant.vin)), [draft.vehicleGrants]);
|
||||
const candidateVehicles = useMemo(() => uniqueVehicles(candidates.data?.items ?? []), [candidates.data?.items]);
|
||||
useEffect(() => {
|
||||
if (candidateVehicles.length === 0) return;
|
||||
@@ -84,7 +99,11 @@ export default function UsersPage() {
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const input: CustomerUserInput = { displayName: draft.displayName.trim(), password: draft.password || undefined, status: draft.status, customerRef: draft.customerRef.trim(), tenantRef: draft.tenantRef.trim(), menuKeys: draft.menuKeys, vehicleVins: draft.vehicleVins };
|
||||
const input: CustomerUserInput = {
|
||||
displayName: draft.displayName.trim(), password: draft.password || undefined, status: draft.status,
|
||||
customerRef: draft.customerRef.trim(), tenantRef: draft.tenantRef.trim(), menuKeys: draft.menuKeys,
|
||||
vehicleVins: draft.vehicleGrants.map((grant) => grant.vin), vehicleGrants: draft.vehicleGrants
|
||||
};
|
||||
if (creating) return api.createCustomerUser({ ...input, username: draft.username.trim(), password: draft.password });
|
||||
if (!selected) throw new Error('请先选择客户账号');
|
||||
return api.updateCustomerUser(selected.id, input);
|
||||
@@ -116,10 +135,22 @@ export default function UsersPage() {
|
||||
setBulkVINs('');
|
||||
setFeedback('');
|
||||
};
|
||||
const toggleVIN = (vin: string) => setDraft((value) => ({ ...value, vehicleVins: value.vehicleVins.includes(vin) ? value.vehicleVins.filter((item) => item !== vin) : [...value.vehicleVins, vin].sort() }));
|
||||
const toggleVIN = (vin: string) => setDraft((value) => ({
|
||||
...value,
|
||||
vehicleGrants: value.vehicleGrants.some((grant) => grant.vin === vin)
|
||||
? value.vehicleGrants.filter((grant) => grant.vin !== vin)
|
||||
: [...value.vehicleGrants, { vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }].sort((left, right) => left.vin.localeCompare(right.vin))
|
||||
}));
|
||||
const updateGrant = (vin: string, patch: Partial<CustomerVehicleGrantInput>) => setDraft((value) => ({
|
||||
...value, vehicleGrants: value.vehicleGrants.map((grant) => grant.vin === vin ? { ...grant, ...patch } : grant)
|
||||
}));
|
||||
const addBulkVINs = () => {
|
||||
const next = bulkVINs.split(/[\s,,;;]+/).map((value) => value.trim().toUpperCase()).filter(Boolean);
|
||||
setDraft((value) => ({ ...value, vehicleVins: [...new Set([...value.vehicleVins, ...next])].sort() }));
|
||||
setDraft((value) => {
|
||||
const existing = new Set(value.vehicleGrants.map((grant) => grant.vin));
|
||||
const added = next.filter((vin) => !existing.has(vin)).map((vin) => ({ vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }));
|
||||
return { ...value, vehicleGrants: [...value.vehicleGrants, ...added].sort((left, right) => left.vin.localeCompare(right.vin)) };
|
||||
});
|
||||
setBulkVINs('');
|
||||
};
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); setFeedback(''); save.mutate(); };
|
||||
@@ -134,7 +165,7 @@ export default function UsersPage() {
|
||||
<div className="v2-user-list-summary"><strong>{customers.length}</strong><span>个客户账号</span></div>
|
||||
{users.isPending ? <p className="v2-user-empty">正在加载账号…</p> : customers.length === 0 ? <p className="v2-user-empty">还没有客户账号</p> : customers.map((user) => <button key={user.id} type="button" className={selectedID === user.id && !creating ? 'is-active' : ''} onClick={() => selectCustomer(user)}>
|
||||
<span className="v2-user-avatar">{user.displayName.slice(0, 1)}</span>
|
||||
<span><b>{user.displayName}</b><small>@{user.username} · {user.vehicleVins.length} 辆车</small></span>
|
||||
<span><b>{user.displayName}</b><small>@{user.username} · {user.vehicles.length} 辆授权车</small></span>
|
||||
<i className={user.status === 'enabled' ? 'is-enabled' : ''}>{user.status === 'enabled' ? '启用' : '停用'}</i>
|
||||
</button>)}
|
||||
</aside>
|
||||
@@ -148,10 +179,14 @@ export default function UsersPage() {
|
||||
<label><span>客户标识(可选)</span><input value={draft.customerRef} onChange={(event) => setDraft((value) => ({ ...value, customerRef: event.target.value }))} placeholder="为 OneOS / RuoYi 映射预留" /></label>
|
||||
</div></section>
|
||||
<section><h4>菜单权限 <small>只能分配客户开放菜单</small></h4><div className="v2-menu-permissions">{customerMenus.map((menu) => <label key={menu.key} className={draft.menuKeys.includes(menu.key) ? 'is-selected' : ''}><input type="checkbox" checked={draft.menuKeys.includes(menu.key)} onChange={() => setDraft((value) => ({ ...value, menuKeys: value.menuKeys.includes(menu.key) ? value.menuKeys.filter((item) => item !== menu.key) : [...value.menuKeys, menu.key] }))} /><span><b>{menu.label}</b><small>{menu.description}</small></span></label>)}</div></section>
|
||||
<section><div className="v2-vehicle-permission-heading"><h4>车辆权限 <small>已选择 {draft.vehicleVins.length} 辆</small></h4>{draft.vehicleVins.length ? <button type="button" onClick={() => setDraft((value) => ({ ...value, vehicleVins: [] }))}>清空</button> : null}</div>
|
||||
<section><div className="v2-vehicle-permission-heading"><h4>车辆权限与有效期 <small>已选择 {draft.vehicleGrants.length} 辆</small></h4>{draft.vehicleGrants.length ? <button type="button" onClick={() => setDraft((value) => ({ ...value, vehicleGrants: [] }))}>清空</button> : null}</div>
|
||||
<div className="v2-vehicle-permission-tools"><label><span>按车牌或 VIN 搜索</span><input value={vehicleKeyword} onChange={(event) => setVehicleKeyword(event.target.value)} placeholder="输入后显示候选车辆" /></label><label><span>批量粘贴 VIN</span><span className="v2-bulk-vin"><input value={bulkVINs} onChange={(event) => setBulkVINs(event.target.value)} placeholder="空格、逗号或换行分隔" /><button type="button" disabled={!bulkVINs.trim()} onClick={addBulkVINs}>加入</button></span></label></div>
|
||||
{deferredVehicleKeyword ? <div className="v2-vehicle-candidates">{candidates.isPending ? <p>正在搜索车辆…</p> : candidateVehicles.length === 0 ? <p>没有匹配车辆</p> : candidateVehicles.map((vehicle) => <button type="button" className={assigned.has(vehicle.vin) ? 'is-selected' : ''} key={vehicle.vin} onClick={() => toggleVIN(vehicle.vin)}><span><b>{vehicle.plate || '未登记车牌'}</b><small>{vehicle.vin}</small></span><i>{assigned.has(vehicle.vin) ? '已分配' : '选择'}</i></button>)}</div> : null}
|
||||
{draft.vehicleVins.length ? <div className="v2-assigned-vins">{draft.vehicleVins.map((vin) => { const plate = vehicleLabels[vin]; return <button type="button" key={vin} aria-label={`移除 ${plate || vin}`} onClick={() => toggleVIN(vin)}><span><b>{plate || '未登记车牌'}</b><small>{vin}</small></span><i>×</i></button>; })}</div> : <p className="v2-user-empty">尚未分配车辆,客户将无法看到任何车辆数据。</p>}
|
||||
{draft.vehicleGrants.length ? <div className="v2-assigned-vins">{draft.vehicleGrants.map((grant) => { const plate = vehicleLabels[grant.vin]; return <article key={grant.vin}>
|
||||
<header><span><b>{plate || '未登记车牌'}</b><small>{grant.vin}</small></span><button type="button" aria-label={`移除 ${plate || grant.vin}`} onClick={() => toggleVIN(grant.vin)}>×</button></header>
|
||||
<div><label><span>启用时间</span><input aria-label={`${plate || grant.vin} 启用时间`} type="datetime-local" required value={grant.validFrom} onChange={(event) => updateGrant(grant.vin, { validFrom: event.target.value })} /></label><label><span>停用时间(可选)</span><input aria-label={`${plate || grant.vin} 停用时间`} type="datetime-local" min={grant.validFrom} value={grant.validTo} onChange={(event) => updateGrant(grant.vin, { validTo: event.target.value })} /></label></div>
|
||||
</article>; })}</div> : <p className="v2-user-empty">尚未分配车辆,客户将无法看到任何车辆数据。</p>}
|
||||
{!creating && selected?.grantHistory?.length ? <details className="v2-grant-history"><summary>历次开放记录 · {selected.grantHistory.length} 条</summary><div>{selected.grantHistory.map((item) => <article key={item.id}><header><strong>{item.plate || '未登记车牌'}</strong><span>{item.vin}</span></header><p>{formatTime(item.validFrom)} → {item.validTo ? formatTime(item.validTo) : '持续有效'}</p><small>开通:{item.grantedBy || '—'}{item.revokedBy ? ` · 停用:${item.revokedBy}` : ''} · {item.sourceSystem}</small></article>)}</div></details> : null}
|
||||
</section>
|
||||
{feedback ? <p className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} role="status">{feedback}</p> : null}
|
||||
<footer><button type="submit" disabled={save.isPending}>{save.isPending ? '正在保存…' : creating ? '创建账号' : '保存权限'}</button></footer>
|
||||
|
||||
@@ -1550,7 +1550,25 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-vehicle-candidates > p { grid-column: 1/-1; margin: 16px; color: #8996a7; font-size: 10px; }
|
||||
.v2-vehicle-candidates > button { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; border: 0; border-radius: 6px; background: transparent; padding: 8px; text-align: left; cursor: pointer; }.v2-vehicle-candidates > button:hover { background: #f4f7fa; }.v2-vehicle-candidates > button.is-selected { background: #edf4ff; }
|
||||
.v2-vehicle-candidates b, .v2-vehicle-candidates small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-vehicle-candidates b { color: #34455c; font-size: 10px; }.v2-vehicle-candidates small { margin-top: 3px; color: #8a96a6; font-size: 8px; }.v2-vehicle-candidates i { color: var(--v2-blue); font-size: 9px; font-style: normal; }
|
||||
.v2-assigned-vins { display: grid; max-height: 150px; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 6px; overflow: auto; margin-top: 10px; }.v2-assigned-vins button { display: flex; min-width: 0; min-height: 42px; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid #dbe4ef; border-radius: 7px; background: #f7f9fc; padding: 6px 8px; color: #52637a; text-align: left; cursor: pointer; }.v2-assigned-vins button > span { min-width: 0; }.v2-assigned-vins b,.v2-assigned-vins small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-assigned-vins b { color: #34455c; font-size: 10px; }.v2-assigned-vins small { margin-top: 3px; color: #8a96a6; font: 8px ui-monospace,SFMono-Regular,Menlo,monospace; }.v2-assigned-vins i { flex: 0 0 auto; color: #99a4b3; font-size: 13px; font-style: normal; }
|
||||
.v2-assigned-vins { display: grid; max-height: 330px; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; overflow: auto; margin-top: 10px; }
|
||||
.v2-assigned-vins > article { min-width: 0; border: 1px solid #dbe4ef; border-radius: 9px; background: #f8fafc; padding: 10px; }
|
||||
.v2-assigned-vins article > header { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||
.v2-assigned-vins article > header > span { min-width: 0; }
|
||||
.v2-assigned-vins article > header button { width: 24px; height: 24px; flex: 0 0 auto; border: 0; border-radius: 6px; background: transparent; color: #99a4b3; cursor: pointer; font-size: 15px; }
|
||||
.v2-assigned-vins article > header button:hover { background: #eef2f7; color: var(--v2-red); }
|
||||
.v2-assigned-vins b,.v2-assigned-vins small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-assigned-vins b { color: #34455c; font-size: 11px; }.v2-assigned-vins small { margin-top: 3px; color: #8a96a6; font: 9px ui-monospace,SFMono-Regular,Menlo,monospace; }
|
||||
.v2-assigned-vins article > div { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin-top: 9px; }
|
||||
.v2-assigned-vins label { min-width: 0; color: #758296; font-size: 9px; }
|
||||
.v2-assigned-vins label span { display: block; margin-bottom: 4px; }
|
||||
.v2-assigned-vins input { width: 100%; height: 32px; border: 1px solid #dbe3ed; border-radius: 6px; background: #fff; padding: 0 7px; color: #34455c; font-size: 10px; }
|
||||
.v2-grant-history { margin-top: 12px; border: 1px solid #e1e7ef; border-radius: 9px; background: #fff; }
|
||||
.v2-grant-history summary { padding: 10px 12px; color: #4c5f78; cursor: pointer; font-size: 10px; font-weight: 700; }
|
||||
.v2-grant-history > div { display: grid; max-height: 250px; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 7px; overflow: auto; border-top: 1px solid #edf1f5; padding: 8px; }
|
||||
.v2-grant-history article { min-width: 0; border-radius: 7px; background: #f7f9fc; padding: 9px; }
|
||||
.v2-grant-history article header { display: flex; min-width: 0; justify-content: space-between; gap: 8px; color: #34455c; font-size: 10px; }
|
||||
.v2-grant-history article header span { overflow: hidden; color: #8290a3; font: 8px ui-monospace,SFMono-Regular,Menlo,monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-grant-history article p { margin: 6px 0 4px; color: #4d6078; font-size: 9px; }
|
||||
.v2-grant-history article small { color: #8996a7; font-size: 8px; line-height: 1.5; }
|
||||
.v2-user-empty { margin: 14px 4px; color: #8b97a7; font-size: 10px; line-height: 1.6; }
|
||||
.v2-user-feedback { margin: 14px 0 0; color: #16805b; font-size: 11px; }.v2-user-feedback.is-error { color: var(--v2-red); }
|
||||
.v2-user-editor form > footer { display: flex; justify-content: flex-end; margin-top: 18px; }.v2-user-editor form > footer button:disabled { opacity: .5; }
|
||||
@@ -1570,7 +1588,8 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-user-admin-heading { align-items: flex-start; margin-bottom: 10px; }.v2-user-admin-heading h2 { font-size: 17px; }.v2-user-admin-heading p { display: none; }.v2-user-admin-heading > button { height: 34px; flex: 0 0 auto; padding: 0 10px; font-size: 10px; }
|
||||
.v2-user-admin-grid { display: flex; min-height: 0; overflow: visible; border: 0; box-shadow: none; flex-direction: column; gap: 8px; background: transparent; }
|
||||
.v2-user-list { display: flex; border: 1px solid var(--v2-border); border-radius: 10px; overflow-x: auto; padding: 6px; background: #fff; }.v2-user-list-summary { flex: 0 0 auto; padding: 8px; }.v2-user-list > button { min-width: 210px; }
|
||||
.v2-user-editor { border: 1px solid var(--v2-border); border-radius: 10px; overflow: hidden; background: #fff; }.v2-user-editor form { padding: 16px 14px 22px; }.v2-user-fields, .v2-menu-permissions, .v2-vehicle-permission-tools, .v2-vehicle-candidates, .v2-assigned-vins { grid-template-columns: 1fr; }
|
||||
.v2-user-editor { border: 1px solid var(--v2-border); border-radius: 10px; overflow: hidden; background: #fff; }.v2-user-editor form { padding: 16px 14px 22px; }.v2-user-fields, .v2-menu-permissions, .v2-vehicle-permission-tools, .v2-vehicle-candidates, .v2-assigned-vins, .v2-grant-history > div { grid-template-columns: 1fr; }
|
||||
.v2-assigned-vins article > div { grid-template-columns: 1fr; }
|
||||
.v2-password-mobile { display: grid !important; }
|
||||
.v2-topbar-actions .v2-current-user { display: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user