mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
feat: clarify account history sync route (#1187)
* feat: clarify account history sync route * fix: clarify shared context command examples * fix: report missing bare profile settings
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Dashboard Authentication CLI
|
||||
|
||||
Last Updated: 2026-04-06
|
||||
Last Updated: 2026-05-05
|
||||
|
||||
CLI commands for managing CCS dashboard authentication.
|
||||
|
||||
@@ -27,7 +27,16 @@ Dashboard auth and account context metadata are separate:
|
||||
- `dashboard_auth`: protects dashboard access with username/password
|
||||
- `accounts.<name>.context_mode/context_group`: controls isolated vs shared account context
|
||||
|
||||
Account context is isolation-first:
|
||||
Account context is isolation-first. The recommended two-account route is:
|
||||
|
||||
```bash
|
||||
ccs auth create work
|
||||
ccs auth create personal
|
||||
ccs work
|
||||
ccs personal
|
||||
```
|
||||
|
||||
Only enable history sync when both accounts should share local continuity while tokens stay separate:
|
||||
|
||||
| Mode | Default | Requirement |
|
||||
|------|---------|-------------|
|
||||
@@ -39,6 +48,16 @@ Shared continuity depth:
|
||||
- `standard` (default): shares project workspace context only
|
||||
- `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
|
||||
|
||||
`ccs auth show <profile>` reports credential isolation, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane.
|
||||
|
||||
Non-bare account profiles share the basic Claude `settings.json` with native Claude:
|
||||
|
||||
```text
|
||||
~/.ccs/instances/<profile>/settings.json -> ~/.ccs/shared/settings.json -> ~/.claude/settings.json
|
||||
```
|
||||
|
||||
This keeps ordinary Claude settings in sync without copying account tokens. Local history is separate: if users want future plain `ccs` and `ccs ck` sessions to resume from the same account lane, run `ccs auth default ck` after backing up the current native lane with `ccs auth backup default`.
|
||||
|
||||
`context_group` normalization and validation:
|
||||
|
||||
- trim + lowercase + collapse internal whitespace to `-`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Session Sharing Technical Analysis
|
||||
|
||||
Last Updated: 2026-04-04
|
||||
Last Updated: 2026-05-05
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -12,6 +12,38 @@ This is implemented as a context policy per account:
|
||||
- `shared` + `standard` (default): account workspace context is linked to a shared context group
|
||||
- `shared` + `deeper` (advanced opt-in): account also shares continuity artifacts
|
||||
|
||||
## Recommended Two-Account Route
|
||||
|
||||
Use `ccs auth` account profiles when you want two real Claude accounts and want to choose which one runs each session:
|
||||
|
||||
```bash
|
||||
ccs auth create work
|
||||
ccs auth create personal
|
||||
|
||||
ccs work
|
||||
ccs personal
|
||||
```
|
||||
|
||||
This keeps usage and credentials isolated. Each account owns its own Claude config directory, login state, and `.anthropic` credentials.
|
||||
|
||||
Basic Claude settings are shared for non-bare account profiles:
|
||||
|
||||
```text
|
||||
~/.ccs/instances/<account>/settings.json
|
||||
-> ~/.ccs/shared/settings.json
|
||||
-> ~/.claude/settings.json
|
||||
```
|
||||
|
||||
This is for ordinary Claude Code settings, hooks, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show <account>` reports the current `Settings`, `History`, and `Plain ccs` lanes so users can see whether settings and resume history are aligned.
|
||||
|
||||
Only opt in to shared history when both accounts should see the same local continuity:
|
||||
|
||||
```bash
|
||||
ccs auth create work2 --share-context --context-group daily --deeper-continuity
|
||||
```
|
||||
|
||||
For existing accounts, use Dashboard -> Accounts -> Sync on both accounts, set both to `shared`, and use the same `History Sync Group`. Use `deeper` only when users expect stronger local handoff beyond project context.
|
||||
|
||||
## Why This Is Safe Enough
|
||||
|
||||
CCS only shares workspace context paths (project/session context files). It does **not** merge or copy authentication credentials between accounts.
|
||||
@@ -93,14 +125,24 @@ continuity:
|
||||
default: work
|
||||
```
|
||||
|
||||
Example with an existing `ck` account:
|
||||
|
||||
```bash
|
||||
ccs auth show ck
|
||||
ccs auth backup default
|
||||
ccs auth default ck
|
||||
```
|
||||
|
||||
`ccs auth default ck` makes future plain `ccs` sessions use the `ck` account lane, so future `ccs` and `ccs ck` resume from the same local inventory. It does not automatically import old native `~/.claude/projects` history into `ck`; keep using `ccs -r` for the old native lane until you intentionally migrate that local history.
|
||||
|
||||
## User Workflows
|
||||
|
||||
### New account with shared context
|
||||
|
||||
```bash
|
||||
ccs auth create work2 --share-context
|
||||
ccs auth create backup --context-group sprint-a
|
||||
ccs auth create backup2 --context-group sprint-a --deeper-continuity
|
||||
ccs auth create backup --share-context --context-group sprint-a
|
||||
ccs auth create backup2 --share-context --context-group sprint-a --deeper-continuity
|
||||
```
|
||||
|
||||
### Existing account
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { DEFAULT_ACCOUNT_CONTEXT_GROUP, type AccountContextPolicy } from './account-context';
|
||||
import { getCcsDir } from '../config/config-loader-facade';
|
||||
import { getDefaultClaudeConfigDir } from '../utils/claude-config-path';
|
||||
|
||||
export type SettingsSyncState = 'shared' | 'profile-local' | 'missing' | 'unknown';
|
||||
|
||||
export interface AccountSettingsSyncSummary {
|
||||
state: SettingsSyncState;
|
||||
profile_settings_path: string;
|
||||
shared_settings_path: string;
|
||||
root_settings_path: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AccountHistorySummary {
|
||||
project_count: number;
|
||||
session_count: number;
|
||||
projects_path: string;
|
||||
projects_shared: boolean;
|
||||
deeper_artifacts_shared: boolean;
|
||||
}
|
||||
|
||||
function safeRealpath(targetPath: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(targetPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function countTopLevelDirectories(targetPath: string): number {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(targetPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory()).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function countJsonFiles(targetPath: string): number {
|
||||
try {
|
||||
return fs.readdirSync(targetPath).filter((entry) => entry.endsWith('.json')).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvesTo(targetPath: string, expectedPath: string): boolean {
|
||||
const realTarget = safeRealpath(targetPath);
|
||||
const realExpected = safeRealpath(expectedPath);
|
||||
return !!realTarget && !!realExpected && realTarget === realExpected;
|
||||
}
|
||||
|
||||
export function describeSettingsSync(
|
||||
instancePath: string,
|
||||
options: { bare?: boolean } = {}
|
||||
): AccountSettingsSyncSummary {
|
||||
const rootSettingsPath = path.join(getDefaultClaudeConfigDir(), 'settings.json');
|
||||
const sharedSettingsPath = path.join(getCcsDir(), 'shared', 'settings.json');
|
||||
const profileSettingsPath = path.join(instancePath, 'settings.json');
|
||||
|
||||
if (options.bare) {
|
||||
if (!fs.existsSync(profileSettingsPath)) {
|
||||
return {
|
||||
state: 'missing',
|
||||
profile_settings_path: profileSettingsPath,
|
||||
shared_settings_path: sharedSettingsPath,
|
||||
root_settings_path: rootSettingsPath,
|
||||
description: 'missing (bare profile; no local settings.json yet)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'profile-local',
|
||||
profile_settings_path: profileSettingsPath,
|
||||
shared_settings_path: sharedSettingsPath,
|
||||
root_settings_path: rootSettingsPath,
|
||||
description: 'profile-local (bare profile; not linked to ~/.claude/settings.json)',
|
||||
};
|
||||
}
|
||||
|
||||
if (!fs.existsSync(profileSettingsPath)) {
|
||||
return {
|
||||
state: 'missing',
|
||||
profile_settings_path: profileSettingsPath,
|
||||
shared_settings_path: sharedSettingsPath,
|
||||
root_settings_path: rootSettingsPath,
|
||||
description: 'missing (run or repair this profile to recreate settings link)',
|
||||
};
|
||||
}
|
||||
|
||||
if (resolvesTo(profileSettingsPath, rootSettingsPath)) {
|
||||
return {
|
||||
state: 'shared',
|
||||
profile_settings_path: profileSettingsPath,
|
||||
shared_settings_path: sharedSettingsPath,
|
||||
root_settings_path: rootSettingsPath,
|
||||
description: 'shared with ~/.claude/settings.json',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'unknown',
|
||||
profile_settings_path: profileSettingsPath,
|
||||
shared_settings_path: sharedSettingsPath,
|
||||
root_settings_path: rootSettingsPath,
|
||||
description: 'not linked to ~/.claude/settings.json',
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeAccountHistory(
|
||||
instancePath: string,
|
||||
policy: AccountContextPolicy
|
||||
): AccountHistorySummary {
|
||||
const projectsPath = path.join(instancePath, 'projects');
|
||||
const sessionEnvPath = path.join(instancePath, 'session-env');
|
||||
const group = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP;
|
||||
const sharedGroupRoot = path.join(getCcsDir(), 'shared', 'context-groups', group);
|
||||
const sharedProjectsPath = path.join(sharedGroupRoot, 'projects');
|
||||
const deeperArtifacts = ['session-env', 'file-history', 'shell-snapshots', 'todos'];
|
||||
|
||||
return {
|
||||
project_count: countTopLevelDirectories(projectsPath),
|
||||
session_count: countJsonFiles(sessionEnvPath),
|
||||
projects_path: projectsPath,
|
||||
projects_shared: policy.mode === 'shared' && resolvesTo(projectsPath, sharedProjectsPath),
|
||||
deeper_artifacts_shared:
|
||||
policy.mode === 'shared' &&
|
||||
policy.continuityMode === 'deeper' &&
|
||||
deeperArtifacts.every((artifact) =>
|
||||
resolvesTo(
|
||||
path.join(instancePath, artifact),
|
||||
path.join(sharedGroupRoot, 'continuity', artifact)
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -81,18 +81,25 @@ class AuthCommands {
|
||||
);
|
||||
console.log('');
|
||||
console.log(subheader('Examples'));
|
||||
console.log(` ${dim('# Create & login to work profile')}`);
|
||||
console.log(` ${dim('# Create two isolated accounts and choose one explicitly at runtime')}`);
|
||||
console.log(` ${color('ccs auth create work', 'command')}`);
|
||||
console.log(` ${color('ccs auth create personal', 'command')}`);
|
||||
console.log(` ${color('ccs work "review code"', 'command')}`);
|
||||
console.log(` ${color('ccs personal "write tests"', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Create account with shared project context (default group)')}`);
|
||||
console.log(
|
||||
` ${dim('# Optional: share local project history while credentials stay isolated')}`
|
||||
);
|
||||
console.log(` ${color('ccs auth create work2 --share-context', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Share context only within a specific group')}`);
|
||||
console.log(` ${color('ccs auth create backup --context-group sprint-a', 'command')}`);
|
||||
console.log(
|
||||
` ${color('ccs auth create backup --share-context --context-group sprint-a', 'command')}`
|
||||
);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Advanced: deeper shared continuity for session history artifacts')}`);
|
||||
console.log(
|
||||
` ${color('ccs auth create backup --context-group sprint-a --deeper-continuity', 'command')}`
|
||||
` ${color('ccs auth create backup --share-context --context-group sprint-a --deeper-continuity', 'command')}`
|
||||
);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Create clean profile without shared commands/skills/agents')}`);
|
||||
@@ -113,9 +120,6 @@ class AuthCommands {
|
||||
console.log(` ${dim('# List all profiles')}`);
|
||||
console.log(` ${color('ccs auth list', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Use work profile')}`);
|
||||
console.log(` ${color('ccs work "review code"', 'command')}`);
|
||||
console.log('');
|
||||
console.log(subheader('Options'));
|
||||
console.log(
|
||||
` ${color('--force', 'command')} Allow overwriting existing profile (create)`
|
||||
@@ -147,14 +151,24 @@ class AuthCommands {
|
||||
` By default, ${color('ccs', 'command')} uses Claude CLI defaults from ~/.claude/`
|
||||
);
|
||||
console.log(
|
||||
` Use ${color('ccs auth default <profile>', 'command')} to change the default profile.`
|
||||
` Recommended two-account route: create ${color('work', 'command')} and ${color('personal', 'command')}, then run the profile you want.`
|
||||
);
|
||||
console.log(
|
||||
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
|
||||
` Use ${color('ccs auth default <profile>', 'command')} to change the default profile.`
|
||||
);
|
||||
console.log(` Account logins, tokens, and .anthropic stay isolated for every profile.`);
|
||||
console.log(
|
||||
` Non-bare account profiles share basic ${color('settings.json', 'path')} with ${color('~/.claude/settings.json', 'path')}; ${color('ccs auth show <profile>', 'command')} shows the link state.`
|
||||
);
|
||||
console.log(
|
||||
` History sync is opt-in: both accounts need shared mode and the same ${color('context_group', 'path')}.`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--deeper-continuity', 'command')} requires shared mode and syncs session-env/file-history/todos/shell-snapshots.`
|
||||
);
|
||||
console.log(
|
||||
` To make future plain ${color('ccs', 'command')} resume with an account, set ${color('ccs auth default <profile>', 'command')}; back up the current native lane first with ${color('ccs auth backup default', 'command')}.`
|
||||
);
|
||||
console.log(
|
||||
` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.`
|
||||
);
|
||||
|
||||
@@ -280,7 +280,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
`Profile: ${profileName}\n` +
|
||||
`Instance: ${instancePath}\n` +
|
||||
`Type: account\n` +
|
||||
`Context: ${formatAccountContextPolicy(contextPolicy)}` +
|
||||
`Context: ${formatAccountContextPolicy(contextPolicy)}\n` +
|
||||
`Tokens: isolated per account\n` +
|
||||
`Settings: ${effectiveBare ? 'profile-local (bare)' : 'shared with ~/.claude/settings.json'}` +
|
||||
(effectiveBare ? '\nMode: bare (no shared symlinks)' : ''),
|
||||
'Profile Created'
|
||||
)
|
||||
@@ -289,6 +291,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
console.log(header('Usage'));
|
||||
console.log(` ${color(`ccs ${profileName} "your prompt here"`, 'command')}`);
|
||||
console.log('');
|
||||
console.log(
|
||||
'To keep two accounts separate, create another account and run either profile by name:'
|
||||
);
|
||||
console.log(` ${color('ccs auth create personal', 'command')}`);
|
||||
console.log(
|
||||
` ${color(`ccs ${profileName}`, 'command')} / ${color('ccs personal', 'command')}`
|
||||
);
|
||||
console.log('');
|
||||
console.log(
|
||||
warnBox(
|
||||
`Running the command below will SWITCH your default\n` +
|
||||
|
||||
@@ -8,10 +8,18 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { initUI, header, color, fail, table } from '../../utils/ui';
|
||||
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
|
||||
import { describeSettingsSync, summarizeAccountHistory } from '../account-profile-diagnostics';
|
||||
import { resolveConfiguredPlainCcsResumeLane } from '../resume-lane-diagnostics';
|
||||
import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import { CommandContext, ProfileOutput, parseArgs } from './types';
|
||||
|
||||
function formatHistorySummary(history: ReturnType<typeof summarizeAccountHistory>): string {
|
||||
const scope = history.projects_shared ? 'shared projects' : 'profile-local projects';
|
||||
const deeper = history.deeper_artifacts_shared ? ', deeper artifacts shared' : '';
|
||||
return `${scope}: ${history.project_count} project(s), ${history.session_count} session env file(s)${deeper}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the show command
|
||||
*/
|
||||
@@ -37,6 +45,11 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
||||
const isDefault = profileName === defaultProfile;
|
||||
const instancePath = ctx.instanceMgr.getInstancePath(profileName);
|
||||
const contextPolicy = resolveAccountContextPolicy(profile);
|
||||
const settingsSync = describeSettingsSync(instancePath, { bare: profile.bare === true });
|
||||
const historySummary = summarizeAccountHistory(instancePath, contextPolicy);
|
||||
const plainCcsLane = await resolveConfiguredPlainCcsResumeLane().catch(() => null);
|
||||
const plainCcsUsesThisAccount =
|
||||
!!plainCcsLane && path.resolve(plainCcsLane.configDir) === path.resolve(instancePath);
|
||||
|
||||
// Count sessions
|
||||
let sessionCount = 0;
|
||||
@@ -63,6 +76,24 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
||||
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
|
||||
instance_path: instancePath,
|
||||
session_count: sessionCount,
|
||||
settings_sync: {
|
||||
state: settingsSync.state,
|
||||
profile_settings_path: settingsSync.profile_settings_path,
|
||||
shared_settings_path: settingsSync.shared_settings_path,
|
||||
root_settings_path: settingsSync.root_settings_path,
|
||||
},
|
||||
history: historySummary,
|
||||
...(plainCcsLane
|
||||
? {
|
||||
plain_ccs_lane: {
|
||||
kind: plainCcsLane.kind,
|
||||
label: plainCcsLane.label,
|
||||
config_dir: plainCcsLane.configDir,
|
||||
project_count: plainCcsLane.projectCount,
|
||||
uses_this_account: plainCcsUsesThisAccount,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(profile.bare ? { bare: true } : {}),
|
||||
};
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
@@ -81,6 +112,19 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
||||
['Created', new Date(profile.created).toLocaleString()],
|
||||
['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
|
||||
['Context', formatAccountContextPolicy(contextPolicy)],
|
||||
['Credentials', 'isolated per account'],
|
||||
['Settings', settingsSync.description],
|
||||
['History', formatHistorySummary(historySummary)],
|
||||
...(plainCcsLane
|
||||
? [
|
||||
[
|
||||
'Plain ccs',
|
||||
plainCcsUsesThisAccount
|
||||
? `uses this account lane (${plainCcsLane.projectCount} project(s))`
|
||||
: `${plainCcsLane.label} (${plainCcsLane.projectCount} project(s))`,
|
||||
],
|
||||
]
|
||||
: []),
|
||||
...(profile.bare ? [['Bare', 'yes (no shared symlinks)']] : []),
|
||||
['Sessions', `${sessionCount}`],
|
||||
];
|
||||
|
||||
@@ -40,6 +40,26 @@ export interface ProfileOutput {
|
||||
continuity_mode?: 'standard' | 'deeper' | null;
|
||||
instance_path?: string;
|
||||
session_count?: number;
|
||||
settings_sync?: {
|
||||
state: 'shared' | 'profile-local' | 'missing' | 'unknown';
|
||||
profile_settings_path: string;
|
||||
shared_settings_path: string;
|
||||
root_settings_path: string;
|
||||
};
|
||||
history?: {
|
||||
project_count: number;
|
||||
session_count: number;
|
||||
projects_path: string;
|
||||
projects_shared: boolean;
|
||||
deeper_artifacts_shared: boolean;
|
||||
};
|
||||
plain_ccs_lane?: {
|
||||
kind: string;
|
||||
label: string;
|
||||
config_dir: string;
|
||||
project_count: number;
|
||||
uses_this_account: boolean;
|
||||
};
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('auth help account route guidance', () => {
|
||||
it('keeps the help copy explicit about account choice and credential isolation', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src', 'auth', 'auth-commands.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
expect(source).toContain('Create two isolated accounts and choose one explicitly at runtime');
|
||||
expect(source).toContain('ccs auth create work');
|
||||
expect(source).toContain('ccs auth create personal');
|
||||
expect(source).toContain('Account logins, tokens, and .anthropic stay isolated');
|
||||
expect(source).toContain('settings.json');
|
||||
expect(source).toContain('ccs auth show <profile>');
|
||||
expect(source).toContain('History sync is opt-in');
|
||||
expect(source).toContain('ccs auth backup default');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
describeSettingsSync,
|
||||
summarizeAccountHistory,
|
||||
} from '../../../src/auth/account-profile-diagnostics';
|
||||
|
||||
describe('account profile diagnostics', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-account-diagnostics-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function seedSharedSettings(instancePath: string): void {
|
||||
const claudeDir = path.join(tempHome, '.claude');
|
||||
const sharedDir = path.join(tempHome, '.ccs', 'shared');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
fs.mkdirSync(sharedDir, { recursive: true });
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}\n');
|
||||
fs.symlinkSync(path.join(claudeDir, 'settings.json'), path.join(sharedDir, 'settings.json'));
|
||||
fs.symlinkSync(path.join(sharedDir, 'settings.json'), path.join(instancePath, 'settings.json'));
|
||||
}
|
||||
|
||||
it('reports non-bare account settings as shared with root Claude settings', () => {
|
||||
const instancePath = path.join(tempHome, '.ccs', 'instances', 'ck');
|
||||
seedSharedSettings(instancePath);
|
||||
|
||||
const summary = describeSettingsSync(instancePath);
|
||||
|
||||
expect(summary.state).toBe('shared');
|
||||
expect(summary.description).toBe('shared with ~/.claude/settings.json');
|
||||
expect(summary.root_settings_path).toBe(path.join(tempHome, '.claude', 'settings.json'));
|
||||
});
|
||||
|
||||
it('reports bare account settings as profile-local', () => {
|
||||
const instancePath = path.join(tempHome, '.ccs', 'instances', 'bare');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
fs.writeFileSync(path.join(instancePath, 'settings.json'), '{}\n');
|
||||
|
||||
const summary = describeSettingsSync(instancePath, { bare: true });
|
||||
|
||||
expect(summary.state).toBe('profile-local');
|
||||
expect(summary.description).toContain('bare profile');
|
||||
});
|
||||
|
||||
it('reports bare account settings as missing when the local file is absent', () => {
|
||||
const instancePath = path.join(tempHome, '.ccs', 'instances', 'bare');
|
||||
|
||||
const summary = describeSettingsSync(instancePath, { bare: true });
|
||||
|
||||
expect(summary.state).toBe('missing');
|
||||
expect(summary.description).toContain('bare profile');
|
||||
expect(summary.profile_settings_path).toBe(path.join(instancePath, 'settings.json'));
|
||||
});
|
||||
|
||||
it('summarizes shared project and deeper continuity lane state', () => {
|
||||
const instancePath = path.join(tempHome, '.ccs', 'instances', 'ck');
|
||||
const groupRoot = path.join(tempHome, '.ccs', 'shared', 'context-groups', 'default');
|
||||
const projectsPath = path.join(groupRoot, 'projects');
|
||||
const sessionEnvPath = path.join(groupRoot, 'continuity', 'session-env');
|
||||
fs.mkdirSync(projectsPath, { recursive: true });
|
||||
fs.mkdirSync(sessionEnvPath, { recursive: true });
|
||||
fs.mkdirSync(path.join(projectsPath, 'project-a'));
|
||||
fs.mkdirSync(path.join(projectsPath, 'project-b'));
|
||||
fs.writeFileSync(path.join(sessionEnvPath, 'session-a.json'), '{}\n');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
fs.symlinkSync(projectsPath, path.join(instancePath, 'projects'), 'dir');
|
||||
|
||||
for (const artifact of ['session-env', 'file-history', 'shell-snapshots', 'todos']) {
|
||||
const sharedArtifactPath = path.join(groupRoot, 'continuity', artifact);
|
||||
fs.mkdirSync(sharedArtifactPath, { recursive: true });
|
||||
fs.symlinkSync(sharedArtifactPath, path.join(instancePath, artifact), 'dir');
|
||||
}
|
||||
|
||||
const summary = summarizeAccountHistory(instancePath, {
|
||||
mode: 'shared',
|
||||
group: 'default',
|
||||
continuityMode: 'deeper',
|
||||
});
|
||||
|
||||
expect(summary.project_count).toBe(2);
|
||||
expect(summary.session_count).toBe(1);
|
||||
expect(summary.projects_shared).toBe(true);
|
||||
expect(summary.deeper_artifacts_shared).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Link2, Settings2, ShieldCheck, Terminal, UserRoundCheck, Waves } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
|
||||
interface AccountRouteGuideCardProps {
|
||||
totalAccounts: number;
|
||||
isolatedCount: number;
|
||||
sharedPeerGroups: string[];
|
||||
deeperReadyGroups: string[];
|
||||
}
|
||||
|
||||
type RouteStatus = 'empty' | 'isolated' | 'shared' | 'deeper' | 'mixed';
|
||||
|
||||
export function AccountRouteGuideCard({
|
||||
totalAccounts,
|
||||
isolatedCount,
|
||||
sharedPeerGroups,
|
||||
deeperReadyGroups,
|
||||
}: AccountRouteGuideCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const recommendedGroup = deeperReadyGroups[0] || sharedPeerGroups[0] || 'daily';
|
||||
const status: RouteStatus =
|
||||
totalAccounts < 2
|
||||
? 'empty'
|
||||
: deeperReadyGroups.length > 0
|
||||
? 'deeper'
|
||||
: sharedPeerGroups.length > 0
|
||||
? 'shared'
|
||||
: totalAccounts >= 2 && isolatedCount === totalAccounts
|
||||
? 'isolated'
|
||||
: totalAccounts === 0
|
||||
? 'empty'
|
||||
: 'mixed';
|
||||
const syncCommand = `ccs auth create work2 --share-context --context-group ${recommendedGroup} --deeper-continuity`;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg">{t('accountRouteGuide.title')}</CardTitle>
|
||||
<CardDescription>{t('accountRouteGuide.description')}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={status === 'deeper' ? 'default' : 'secondary'}>
|
||||
{t(`accountRouteGuide.status.${status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<section className="rounded-md border bg-blue-50/50 p-3 text-sm dark:bg-blue-900/10">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<ShieldCheck className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
{t('accountRouteGuide.cards.isolated.title')}
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
|
||||
{t('accountRouteGuide.cards.isolated.desc')}
|
||||
</p>
|
||||
</section>
|
||||
<section className="rounded-md border bg-emerald-50/50 p-3 text-sm dark:bg-emerald-900/10">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<UserRoundCheck className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
{t('accountRouteGuide.cards.select.title')}
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
|
||||
{t('accountRouteGuide.cards.select.desc')}
|
||||
</p>
|
||||
</section>
|
||||
<section className="rounded-md border bg-sky-50/50 p-3 text-sm dark:bg-sky-900/10">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Settings2 className="h-4 w-4 text-sky-600 dark:text-sky-400" />
|
||||
{t('accountRouteGuide.cards.settings.title')}
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
|
||||
{t('accountRouteGuide.cards.settings.desc')}
|
||||
</p>
|
||||
</section>
|
||||
<section className="rounded-md border bg-indigo-50/50 p-3 text-sm dark:bg-indigo-900/10">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Link2 className="h-4 w-4 text-indigo-600 dark:text-indigo-400" />
|
||||
{t('accountRouteGuide.cards.sync.title')}
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
|
||||
{t('accountRouteGuide.cards.sync.desc')}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="rounded-md border bg-background p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
{t('accountRouteGuide.commands.isolated')}
|
||||
</div>
|
||||
{['ccs auth create work', 'ccs auth create personal', 'ccs work', 'ccs personal'].map(
|
||||
(command) => (
|
||||
<div
|
||||
key={command}
|
||||
className="mt-2 flex items-start gap-2 rounded-md bg-muted px-2 py-2 font-mono text-[11px]"
|
||||
>
|
||||
<span className="flex-1 break-all">{command}</span>
|
||||
<CopyButton value={command} size="icon" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border bg-background p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<Waves className="h-3.5 w-3.5" />
|
||||
{t('accountRouteGuide.commands.sync')}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-md bg-muted px-2 py-2 font-mono text-[11px]">
|
||||
<span className="flex-1 break-all">{syncCommand}</span>
|
||||
<CopyButton value={syncCommand} size="icon" />
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{t('accountRouteGuide.commands.syncDesc', { group: recommendedGroup })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -253,6 +253,42 @@ const resources = {
|
||||
copied: 'Copied!',
|
||||
copyCommand: 'Copy Command',
|
||||
},
|
||||
accountRouteGuide: {
|
||||
title: 'Recommended two-account route',
|
||||
description:
|
||||
'Use separate account profiles for token isolation. Turn on history sync only when both accounts should see the same local continuity.',
|
||||
status: {
|
||||
empty: 'create accounts',
|
||||
isolated: 'isolated ready',
|
||||
shared: 'shared project context',
|
||||
deeper: 'deeper sync ready',
|
||||
mixed: 'mixed setup',
|
||||
},
|
||||
cards: {
|
||||
isolated: {
|
||||
title: 'Tokens stay separate',
|
||||
desc: 'Each ccs auth account owns its own Claude config, login, and credentials. Sync settings never copy tokens.',
|
||||
},
|
||||
select: {
|
||||
title: 'You choose the account',
|
||||
desc: 'Run ccs work or ccs personal for explicit usage. Set Default only when plain ccs should follow one account.',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings follow root',
|
||||
desc: 'Non-bare accounts link settings.json through CCS shared state to ~/.claude/settings.json. ccs auth show confirms it.',
|
||||
},
|
||||
sync: {
|
||||
title: 'History sync is opt-in',
|
||||
desc: 'Both accounts need Shared mode and the same group. Deeper sync adds extra local continuity artifacts.',
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
isolated: 'Isolated account usage',
|
||||
sync: 'Optional shared history',
|
||||
syncDesc:
|
||||
'Use the same group, such as "{{group}}", on both accounts when you want them to share local history.',
|
||||
},
|
||||
},
|
||||
cliproxyModelCategory: {
|
||||
google: 'Google (Gemini)',
|
||||
openai: 'OpenAI (GPT)',
|
||||
@@ -2819,6 +2855,41 @@ const resources = {
|
||||
copied: '已复制!',
|
||||
copyCommand: '复制命令',
|
||||
},
|
||||
accountRouteGuide: {
|
||||
title: '推荐的双账号路线',
|
||||
description:
|
||||
'使用独立 account profile 保持 token 隔离。只有当两个账号应该看到同一份本地连续性时,才开启历史同步。',
|
||||
status: {
|
||||
empty: '创建账号',
|
||||
isolated: '隔离已就绪',
|
||||
shared: '共享项目上下文',
|
||||
deeper: '更深同步已就绪',
|
||||
mixed: '混合设置',
|
||||
},
|
||||
cards: {
|
||||
isolated: {
|
||||
title: 'Token 仍然隔离',
|
||||
desc: '每个 ccs auth 账号都有自己的 Claude 配置、登录和凭据。同步设置不会复制 token。',
|
||||
},
|
||||
select: {
|
||||
title: '你选择使用哪个账号',
|
||||
desc: '运行 ccs work 或 ccs personal 来明确选择账号。只有当普通 ccs 应跟随某个账号时才设置默认。',
|
||||
},
|
||||
settings: {
|
||||
title: '设置跟随根目录',
|
||||
desc: '非 bare 账号会通过 CCS shared state 将 settings.json 链接到 ~/.claude/settings.json。ccs auth show 会确认状态。',
|
||||
},
|
||||
sync: {
|
||||
title: '历史同步需要主动开启',
|
||||
desc: '两个账号都需要使用 Shared 模式和同一分组。更深同步会加入额外的本地连续性文件。',
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
isolated: '隔离账号用法',
|
||||
sync: '可选共享历史',
|
||||
syncDesc: '当希望两个账号共享本地历史时,在两个账号上使用同一分组,例如 "{{group}}"。',
|
||||
},
|
||||
},
|
||||
cliproxyModelCategory: {
|
||||
google: 'Google(Gemini)',
|
||||
openai: 'OpenAI(GPT)',
|
||||
@@ -5282,6 +5353,42 @@ const resources = {
|
||||
copied: 'Đã sao chép!',
|
||||
copyCommand: 'Sao chép lệnh',
|
||||
},
|
||||
accountRouteGuide: {
|
||||
title: 'Lộ trình hai tài khoản khuyến nghị',
|
||||
description:
|
||||
'Dùng account profile riêng để giữ token tách biệt. Chỉ bật history sync khi cả hai tài khoản cần thấy cùng continuity cục bộ.',
|
||||
status: {
|
||||
empty: 'tạo tài khoản',
|
||||
isolated: 'đã sẵn sàng tách biệt',
|
||||
shared: 'chia sẻ context project',
|
||||
deeper: 'deeper sync sẵn sàng',
|
||||
mixed: 'thiết lập hỗn hợp',
|
||||
},
|
||||
cards: {
|
||||
isolated: {
|
||||
title: 'Token vẫn tách biệt',
|
||||
desc: 'Mỗi tài khoản ccs auth có Claude config, login và credential riêng. Cài đặt sync không bao giờ copy token.',
|
||||
},
|
||||
select: {
|
||||
title: 'Bạn chọn tài khoản cần dùng',
|
||||
desc: 'Chạy ccs work hoặc ccs personal để chọn rõ tài khoản. Chỉ đặt Default khi plain ccs nên đi theo một tài khoản.',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings đi theo root',
|
||||
desc: 'Tài khoản không bare link settings.json qua CCS shared state tới ~/.claude/settings.json. ccs auth show sẽ xác nhận.',
|
||||
},
|
||||
sync: {
|
||||
title: 'History sync là tùy chọn',
|
||||
desc: 'Cả hai tài khoản cần Shared mode và cùng group. Deeper sync thêm các artifact continuity cục bộ.',
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
isolated: 'Dùng tài khoản tách biệt',
|
||||
sync: 'Chia sẻ lịch sử tùy chọn',
|
||||
syncDesc:
|
||||
'Dùng cùng group, ví dụ "{{group}}", trên cả hai tài khoản khi muốn chia sẻ lịch sử cục bộ.',
|
||||
},
|
||||
},
|
||||
cliproxyModelCategory: {
|
||||
google: 'Google (Gemini)',
|
||||
openai: 'OpenAI (GPT)',
|
||||
@@ -7848,6 +7955,42 @@ const resources = {
|
||||
copied: 'コピーしました!',
|
||||
copyCommand: 'コマンドをコピー',
|
||||
},
|
||||
accountRouteGuide: {
|
||||
title: '推奨される 2 アカウント構成',
|
||||
description:
|
||||
'トークンを分離するため、別々の account profile を使います。両方のアカウントで同じローカル継続性を見たい場合だけ履歴同期を有効にします。',
|
||||
status: {
|
||||
empty: 'アカウントを作成',
|
||||
isolated: '分離準備済み',
|
||||
shared: 'プロジェクト文脈を共有',
|
||||
deeper: '拡張同期準備済み',
|
||||
mixed: '混在設定',
|
||||
},
|
||||
cards: {
|
||||
isolated: {
|
||||
title: 'トークンは分離されたまま',
|
||||
desc: '各 ccs auth アカウントは専用の Claude 設定、ログイン、認証情報を持ちます。同期設定でトークンはコピーされません。',
|
||||
},
|
||||
select: {
|
||||
title: '使うアカウントを選ぶ',
|
||||
desc: 'ccs work または ccs personal を実行して明示的に選びます。通常の ccs を特定アカウントに合わせたい場合だけ既定を設定します。',
|
||||
},
|
||||
settings: {
|
||||
title: '設定はルートに追従',
|
||||
desc: '非 bare アカウントは CCS shared state 経由で settings.json を ~/.claude/settings.json にリンクします。ccs auth show で確認できます。',
|
||||
},
|
||||
sync: {
|
||||
title: '履歴同期は任意',
|
||||
desc: '両方のアカウントで共有モードと同じグループが必要です。拡張同期では追加のローカル継続性ファイルも共有します。',
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
isolated: '分離アカウントの使用',
|
||||
sync: '任意の共有履歴',
|
||||
syncDesc:
|
||||
'ローカル履歴を共有したい場合は、両方のアカウントで "{{group}}" など同じグループを使ってください。',
|
||||
},
|
||||
},
|
||||
cliproxyModelCategory: {
|
||||
google: 'Google (Gemini)',
|
||||
openai: 'OpenAI (GPT)',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertTriangle, ArrowRight, Plus, Users, Zap } from 'lucide-react';
|
||||
import { AccountsTable } from '@/components/account/accounts-table';
|
||||
import { AccountRouteGuideCard } from '@/components/account/account-route-guide-card';
|
||||
import { ContinuityOverview } from '@/components/account/continuity-overview';
|
||||
import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
@@ -151,10 +152,11 @@ export function AccountsPage() {
|
||||
<CardContent className="space-y-2">
|
||||
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
|
||||
<span className="flex-1 break-all">
|
||||
ccs auth create work --context-group sprint-a --deeper-continuity
|
||||
ccs auth create work --share-context --context-group sprint-a
|
||||
--deeper-continuity
|
||||
</span>
|
||||
<CopyButton
|
||||
value="ccs auth create work --context-group sprint-a --deeper-continuity"
|
||||
value="ccs auth create work --share-context --context-group sprint-a --deeper-continuity"
|
||||
size="icon"
|
||||
/>
|
||||
</div>
|
||||
@@ -205,6 +207,13 @@ export function AccountsPage() {
|
||||
plainCcsLane={plainCcsLane}
|
||||
/>
|
||||
|
||||
<AccountRouteGuideCard
|
||||
totalAccounts={authAccounts.length}
|
||||
isolatedCount={isolatedCount}
|
||||
sharedPeerGroups={sharedPeerGroups}
|
||||
deeperReadyGroups={deeperReadyGroups}
|
||||
/>
|
||||
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">{t('accountsPage.accountMatrix')}</CardTitle>
|
||||
@@ -283,6 +292,13 @@ export function AccountsPage() {
|
||||
plainCcsLane={plainCcsLane}
|
||||
/>
|
||||
|
||||
<AccountRouteGuideCard
|
||||
totalAccounts={authAccounts.length}
|
||||
isolatedCount={isolatedCount}
|
||||
sharedPeerGroups={sharedPeerGroups}
|
||||
deeperReadyGroups={deeperReadyGroups}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t('accountsPage.accountMatrix')}</CardTitle>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { AccountRouteGuideCard } from '@/components/account/account-route-guide-card';
|
||||
import { render, screen } from '@tests/setup/test-utils';
|
||||
|
||||
describe('AccountRouteGuideCard', () => {
|
||||
it('explains explicit two-account usage while tokens stay isolated', async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
|
||||
render(
|
||||
<AccountRouteGuideCard
|
||||
totalAccounts={2}
|
||||
isolatedCount={2}
|
||||
sharedPeerGroups={[]}
|
||||
deeperReadyGroups={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Recommended two-account route')).toBeInTheDocument();
|
||||
expect(screen.getByText('isolated ready')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tokens stay separate')).toBeInTheDocument();
|
||||
expect(screen.getByText('Settings follow root')).toBeInTheDocument();
|
||||
expect(screen.getByText('ccs auth create work')).toBeInTheDocument();
|
||||
expect(screen.getByText('ccs auth create personal')).toBeInTheDocument();
|
||||
expect(screen.getByText('ccs work')).toBeInTheDocument();
|
||||
expect(screen.getByText('ccs personal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses the active deeper-ready group in optional sync guidance', async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
|
||||
render(
|
||||
<AccountRouteGuideCard
|
||||
totalAccounts={2}
|
||||
isolatedCount={0}
|
||||
sharedPeerGroups={['sprint-a']}
|
||||
deeperReadyGroups={['sprint-a']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('deeper sync ready')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'ccs auth create work2 --share-context --context-group sprint-a --deeper-continuity'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Use the same group, such as "sprint-a", on both accounts when you want them to share local history.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user