Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/web/src/frontend/app/store/useSqlEditorStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { beamAliasesForCount, MAX_SERVERS } from '@foxschema/shared';
import { buildSampleBookmarks } from '@/features/sql-editor/lib/sqlEditorSamples';
import {
buildForeignKeyDrilldown,
buildOrphanPeek,
buildRowLookup,
buildTablePreview,
composePeekSql,
Expand Down Expand Up @@ -522,6 +523,17 @@ interface SqlEditorState {
fk: ForeignKeyInfo,
values: unknown[]
) => Promise<void>;
/**
* Open the child rows whose foreign key points at a parent row that is gone.
*
* A scan, unlike the rest of Peek's catalog reads, so it is reached only by
* asking for it from the Insight tab.
*/
openDataPeekOrphans: (
connectionId: string,
childTable: string,
fk: ForeignKeyInfo
) => Promise<void>;
/**
* Open one row of one table, given its key — the way out of a joined result.
*
Expand Down Expand Up @@ -1828,6 +1840,31 @@ export const useSqlEditorStore = create<SqlEditorState>()(
await get().runDataPeekEntry(entry.id);
},

openDataPeekOrphans: async (connectionId, childTable, fk) => {
const conn = useSyncStore.getState().connections.find((c) => c.id === connectionId);
if (!conn) return;
const built = buildOrphanPeek(childTable, fk, conn.dialect);
if (!built) return;
const composed = composePeekSql(built.sql, built.params, {});
if ('error' in composed) return;
const entry: DataPeekEntry = {
id: `peek-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
title: `${childTable} · orphans via ${fk.name || (fk.columns ?? []).join(', ')}`,
tableName: childTable,
baseSql: built.sql,
baseParams: built.params,
whereClause: '',
orderByClause: '',
limit: DATA_PEEK_ROWS,
pageIndex: 0,
sql: composed.sql,
params: composed.params,
status: 'loading',
};
set({ dataPeek: { connectionId, dialect: conn.dialect, entries: [entry] } });
await get().runDataPeekEntry(entry.id);
},

openDataPeekForRow: async (connectionId, tableName, keys) => {
const conn = useSyncStore.getState().connections.find((c) => c.id === connectionId);
if (!conn) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from 'lucide-react';
import {
dialectSupportsDbAccess,
userManagementSupport,
privilegesForPrincipal,
type DbPrincipal,
type DbPrivilege,
Expand All @@ -35,6 +36,12 @@ import { useAuthStore } from '@/app/store/authStore';
import { EmptyState, Segmented, inputCls, labelCls } from './controls';
import { type DbAccessConfirmRequest } from './DbAccessPermissionSections';
import { PermissionInspector } from './PermissionInspector';
import { SectionLabel } from '@/shared/components/surfaces';
import {
ALTERATION_LABEL,
availableAlterations,
dropSafetyNotes,
} from '../lib/accountAlterations';
import type { AccessPrincipalDraft } from '../lib/access-draft';

type PermissionStage = 'account' | 'grants' | 'effective';
Expand Down Expand Up @@ -465,7 +472,12 @@ export const AccessPermissionPanel: React.FC<{
</p>
)}
{selected && stage === 'account' && (
<AccountStage principal={selected} onManageUsers={onAddUser} />
<AccountStage
principal={selected}
privileges={privileges}
dialect={dialect}
onManageUsers={onAddUser}
/>
)}
{selected && stage === 'grants' && (
<AccessGrantsStage
Expand Down Expand Up @@ -560,8 +572,16 @@ export const AccessPermissionPanel: React.FC<{

const AccountStage: React.FC<{
principal: DbPrincipal;
/** The catalog's privileges, so a drop can be described before it is run. */
privileges?: readonly DbPrivilege[];
dialect?: string;
onManageUsers?: () => void;
}> = ({ principal, onManageUsers }) => {
}> = ({ principal, privileges = [], dialect, onManageUsers }) => {
const support = dialect ? userManagementSupport(dialect) : null;
const alterations = support
? availableAlterations(support, principal.kind === 'user' ? 'user' : 'role')
: [];
const dropNotes = dropSafetyNotes(principal, privileges);
const login =
principal.canLogin === true
? 'Can log in'
Expand All @@ -588,6 +608,46 @@ const AccountStage: React.FC<{
{login}
</dd>
</dl>
{/* What this engine can change about this principal. Naming them here
means the reader learns what is possible without opening the form and
finding out by absence. */}
<div data-testid="access-permission-account-alterations">
<SectionLabel className="mb-1">Alteration</SectionLabel>
{alterations.length === 0 ? (
<p className="text-[11px] text-slate-500">
{support
? 'This engine has no edit actions for this kind of account.'
: 'Choose a connection to see what this engine can change.'}
</p>
) : (
<ul className="flex flex-wrap gap-1.5">
{alterations.map((a) => (
<li
key={a}
data-testid={`access-permission-alteration-${a}`}
className="rounded-md border border-slate-700 px-2 py-0.5 text-[11px] font-semibold text-slate-300"
>
{ALTERATION_LABEL[a]}
</li>
))}
</ul>
)}
</div>

{dropNotes.length > 0 && (
<div
className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-2.5 py-2"
data-testid="access-permission-drop-safety"
>
<SectionLabel className="mb-1">Drop safety notes</SectionLabel>
<ul className="space-y-1 text-[11px] text-amber-100/90">
{dropNotes.map((n) => (
<li key={n}>{n}</li>
))}
</ul>
</div>
)}

{onManageUsers && (
<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,15 @@ import {
import { useSyncStore } from '@/app/store/useSyncStore';
import { useSqlEditorStore } from '@/app/store/useSqlEditorStore';
import { fetchDbAccess } from '@/shared/api/schemaApi';
import {
ALTERATION_LABEL,
availableAlterations,
dropSafetyNotes,
} from '../lib/accountAlterations';
import type { AccessPrincipalDraft } from '../lib/access-draft';

type Mode = 'idle' | 'add' | 'edit' | 'drop' | 'list';

const ALTERATION_LABEL: Record<UserAlteration, string> = {
password: 'Set password',
rename: 'Rename',
disable: 'Disable login',
enable: 'Enable login',
expire: 'Expire password / account',
};

function parseMysqlAccount(raw: string): { name: string; host?: string } {
const s = raw.trim().replace(/^'|'$/g, '');
Expand Down Expand Up @@ -124,50 +122,6 @@ function dialectCoach(dialect: string): string | null {
return 'Review the user and role list, choose Add / Edit / Drop, then copy the SQL preview. Fox Schema never applies it for you.';
}

function availableAlterations(
support: ReturnType<typeof userManagementSupport>,
principalType: PrincipalType
): UserAlteration[] {
const opts: UserAlteration[] = [];
if (principalType === 'user') opts.push('password');
if (support.canRename) opts.push('rename');
if (support.canDisable && principalType === 'user') {
opts.push('disable', 'enable');
}
if (support.canExpire && principalType === 'user') {
opts.push('expire');
}
return opts;
}

function dropSafetyNotes(p: DbPrincipal, privileges: readonly DbPrivilege[]): string[] {
const notes: string[] = [];
const grants = privilegesForPrincipal(privileges, p.name);
if (grants.length > 0) {
const sample = grants
.slice(0, 4)
.map((g) => {
const obj = [g.objectSchema, g.objectName].filter(Boolean).join('.') || g.objectType;
return `${g.privilege} on ${obj}`;
})
.join('; ');
notes.push(
`This account has ${grants.length} recorded privilege${grants.length === 1 ? '' : 's'}` +
(sample ? ` (e.g. ${sample}${grants.length > 4 ? '; …' : ''})` : '') +
'. Dropping it removes those grants with the account.'
);
}
if (p.memberOf.length > 0) {
notes.push(`Member of: ${p.memberOf.join(', ')}. Role membership is removed with the account.`);
}
if (p.members.length > 0) {
notes.push(
`This role has ${p.members.length} member${p.members.length === 1 ? '' : 's'} (${p.members.slice(0, 5).join(', ')}${p.members.length > 5 ? ', …' : ''}). Dropping it does not drop those members.`
);
}
return notes;
}

export const UserManagement: React.FC<{
onGrantAccess?: (draft: AccessPrincipalDraft) => void;
lockedConnectionId?: string;
Expand Down
104 changes: 104 additions & 0 deletions apps/web/src/frontend/features/access/lib/accountAlterations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*
* What an engine will let you change, and what a drop would cost.
*
* Both answers are now read by two screens, so the assertions here are about
* the decisions themselves rather than either screen's markup: never offering
* an alteration the engine cannot express, and never letting a drop go
* unexplained when it would take grants with it.
*/
import { describe, expect, it } from 'vitest';
import { ALTERATION_LABEL, availableAlterations, dropSafetyNotes } from './accountAlterations';
import type { DbPrincipal, DbPrivilege } from '@foxschema/sql';

const support = (over: Partial<Record<string, boolean>> = {}) =>
({ canRename: false, canDisable: false, canExpire: false, ...over }) as never;

const user: DbPrincipal = {
name: 'app_rw',
kind: 'user',
canLogin: true,
memberOf: [],
members: [],
};
const role: DbPrincipal = { ...user, name: 'app_read', kind: 'role', canLogin: false };

const priv = (over: Partial<DbPrivilege> = {}): DbPrivilege =>
({
grantee: 'app_rw',
privilege: 'SELECT',
objectType: 'TABLE',
objectSchema: 'public',
objectName: 'orders',
grantable: false,
grantor: null,
state: 'grant',
...over,
}) as DbPrivilege;

describe('availableAlterations', () => {
it('never offers a password on a role', () => {
// A role has none. Offering it generates SQL the server rejects.
expect(availableAlterations(support({ canRename: true }), 'role')).not.toContain('password');
expect(availableAlterations(support(), 'user')).toContain('password');
});

it('offers only what the engine says it can express', () => {
expect(availableAlterations(support(), 'user')).toEqual(['password']);
expect(availableAlterations(support({ canRename: true }), 'user')).toEqual([
'password',
'rename',
]);
});

it('pairs disable with enable, since one without the other is a trap', () => {
const opts = availableAlterations(support({ canDisable: true }), 'user');
expect(opts).toContain('disable');
expect(opts).toContain('enable');
});

it('keeps login-only actions away from roles', () => {
const opts = availableAlterations(support({ canDisable: true, canExpire: true }), 'role');
expect(opts).not.toContain('disable');
expect(opts).not.toContain('expire');
});

it('has a label for every alteration it can return', () => {
// A missing label renders as blank chip, which reads as a broken control.
const all = availableAlterations(
support({ canRename: true, canDisable: true, canExpire: true }),
'user'
);
for (const a of all) expect(ALTERATION_LABEL[a], a).toBeTruthy();
});
});

describe('dropSafetyNotes', () => {
it('says how many grants a drop would take with it', () => {
const notes = dropSafetyNotes(user, [priv(), priv({ privilege: 'INSERT' })]);
expect(notes.join(' ')).toMatch(/2 recorded privileges/);
expect(notes.join(' ')).toMatch(/removes those grants/);
});

it('stays silent when there is nothing to warn about', () => {
// An empty panel is the right answer for an account with nothing attached;
// a reassuring note would just be noise above the button.
expect(dropSafetyNotes(user, [])).toEqual([]);
});

it('distinguishes losing membership from dropping the members', () => {
const notes = dropSafetyNotes(
{ ...role, members: ['alice', 'bob'] },
[]
).join(' ');
expect(notes).toMatch(/does not drop those members/);
});

it('counts only this principal\'s grants', () => {
const notes = dropSafetyNotes(user, [priv(), priv({ grantee: 'someone_else' })]).join(' ');
expect(notes).toMatch(/1 recorded privilege\b/);
});
});
Loading
Loading