Skip to content

Commit 53ab96f

Browse files
authored
Merge pull request #95 from geolonia/feat/multi-tenant-profile
feat: マルチテナント対応 — テナント名ログイン & プロフィル連携
2 parents a1071c9 + b0e1e9c commit 53ab96f

8 files changed

Lines changed: 373 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
## [Unreleased]
99

1010
### 2026-04-03
11+
- **Feat**: マルチテナント対応 — ログイン時のテナント名指定 (`-s <テナント名>`)、`tenantId` / `availableTenants` の config 保存、`profile use` 時のトークン自動リフレッシュ (#90)
1112
- **Feat**: `geonic cli update` コマンドを追加 — `npm update -g` のエイリアスとして CLI を最新版に更新可能に (#94)
1213
- **Fix**: 複数ターミナルで同一プロファイルを使用時、refresh token rotation によりセッションが無効化される問題を修正 — トークンリフレッシュ前に config を再読み込みし、別プロセスが既にリフレッシュ済みならそのトークンを使用する (#93)
1314

src/commands/auth.ts

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,13 @@ function createLoginCommand(): Command {
9595

9696
const client = createClient(cmd);
9797
const body: Record<string, string> = { email, password };
98-
if (loginOpts.tenantId) {
99-
body.tenantId = loginOpts.tenantId;
98+
99+
// --tenant-id takes priority
100+
const requestTenantId = loginOpts.tenantId;
101+
const serviceFlag = globalOpts.service;
102+
103+
if (requestTenantId) {
104+
body.tenantId = requestTenantId;
100105
}
101106

102107
const response = await client.rawRequest("POST", "/auth/login", {
@@ -117,12 +122,31 @@ function createLoginCommand(): Command {
117122
const availableTenants = data.availableTenants as TenantChoice[] | undefined;
118123
let finalTenantId = data.tenantId as string | undefined;
119124

120-
if (availableTenants && availableTenants.length > 1 && !loginOpts.tenantId) {
121-
const selectedTenantId = await promptTenantSelection(availableTenants, finalTenantId);
122-
if (selectedTenantId && selectedTenantId !== finalTenantId) {
125+
if (availableTenants && availableTenants.length > 1 && !requestTenantId) {
126+
// If --service was provided, resolve tenant by name or ID match
127+
let resolvedTenantId: string | undefined;
128+
if (serviceFlag) {
129+
const match = availableTenants.find(
130+
(t) => t.name === serviceFlag || t.tenantId === serviceFlag,
131+
);
132+
if (match) {
133+
resolvedTenantId = match.tenantId;
134+
} else {
135+
printError(
136+
`Tenant "${serviceFlag}" not found. Available: ${availableTenants.map((t) => t.name ?? t.tenantId).join(", ")}`,
137+
);
138+
process.exit(1);
139+
}
140+
}
141+
142+
if (!resolvedTenantId) {
143+
resolvedTenantId = await promptTenantSelection(availableTenants, finalTenantId);
144+
}
145+
146+
if (resolvedTenantId && resolvedTenantId !== finalTenantId) {
123147
// Re-login with selected tenant
124148
const reloginResponse = await client.rawRequest("POST", "/auth/login", {
125-
body: { email, password, tenantId: selectedTenantId },
149+
body: { email, password, tenantId: resolvedTenantId },
126150
skipTenantHeader: true,
127151
});
128152
const reloginData = reloginResponse.data as Record<string, unknown>;
@@ -133,7 +157,7 @@ function createLoginCommand(): Command {
133157
}
134158
token = newToken;
135159
refreshToken = reloginData.refreshToken as string | undefined;
136-
finalTenantId = selectedTenantId;
160+
finalTenantId = resolvedTenantId;
137161
}
138162
}
139163

@@ -146,12 +170,26 @@ function createLoginCommand(): Command {
146170
}
147171
if (finalTenantId) {
148172
config.service = finalTenantId;
173+
config.tenantId = finalTenantId;
149174
} else {
150175
delete config.service;
176+
delete config.tenantId;
177+
}
178+
if (availableTenants && availableTenants.length > 0) {
179+
config.availableTenants = availableTenants.map((t) => ({
180+
tenantId: t.tenantId,
181+
...(t.name ? { name: t.name } : {}),
182+
role: t.role,
183+
}));
184+
} else {
185+
delete config.availableTenants;
151186
}
152187
saveConfig(config, globalOpts.profile);
153188

154-
printSuccess("Login successful. Token saved to config.");
189+
const tenantLabel = finalTenantId
190+
? ` (tenant: ${availableTenants?.find((t) => t.tenantId === finalTenantId)?.name ?? finalTenantId})`
191+
: "";
192+
printSuccess(`Login successful${tenantLabel}. Token saved to config.`);
155193
}),
156194
);
157195
}
@@ -395,9 +433,13 @@ export function registerAuthCommands(program: Command): void {
395433
"geonic auth login --client-credentials --client-id MY_ID --client-secret MY_SECRET",
396434
},
397435
{
398-
description: "Login to a specific tenant",
436+
description: "Login to a specific tenant by ID",
399437
command: "geonic auth login --tenant-id my-tenant",
400438
},
439+
{
440+
description: "Login to a tenant by name",
441+
command: "geonic auth login -s demo_smartcity",
442+
},
401443
]);
402444
auth.addCommand(login);
403445

src/commands/profile.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import {
66
createProfile,
77
deleteProfile,
88
loadConfig,
9+
saveConfig,
10+
validateUrl,
911
} from "../config.js";
10-
import { printSuccess, printInfo, printError } from "../output.js";
12+
import { printSuccess, printInfo, printError, printWarning } from "../output.js";
13+
import { getTokenStatus } from "../token.js";
1114
import { addExamples } from "./help.js";
1215

1316
export function registerProfileCommands(program: Command): void {
@@ -33,15 +36,52 @@ export function registerProfileCommands(program: Command): void {
3336

3437
const use = profile
3538
.command("use <name>")
36-
.description("Switch active profile")
37-
.action((name: string) => {
39+
.description("Switch active profile (auto-refreshes expired tokens)")
40+
.action(async (name: string) => {
3841
try {
3942
setCurrentProfile(name);
40-
printSuccess(`Switched to profile "${name}".`);
4143
} catch (err) {
4244
printError((err as Error).message);
4345
process.exit(1);
4446
}
47+
48+
const config = loadConfig(name);
49+
const tenantLabel = config.tenantId
50+
? ` (tenant: ${config.availableTenants?.find((t) => t.tenantId === config.tenantId)?.name ?? config.tenantId})`
51+
: "";
52+
53+
// Auto-refresh expired token if refreshToken is available
54+
if (config.token && config.refreshToken && config.url) {
55+
const status = getTokenStatus(config.token);
56+
if (status.isExpired || status.isExpiringSoon) {
57+
try {
58+
const baseUrl = validateUrl(config.url);
59+
const url = new URL("/auth/refresh", baseUrl).toString();
60+
const response = await fetch(url, {
61+
method: "POST",
62+
headers: { "Content-Type": "application/json" },
63+
body: JSON.stringify({ refreshToken: config.refreshToken }),
64+
});
65+
if (response.ok) {
66+
const data = (await response.json()) as Record<string, unknown>;
67+
const newToken = (data.accessToken ?? data.token) as string | undefined;
68+
const newRefreshToken = data.refreshToken as string | undefined;
69+
if (newToken) {
70+
config.token = newToken;
71+
if (newRefreshToken) config.refreshToken = newRefreshToken;
72+
saveConfig(config, name);
73+
printSuccess(`Switched to profile "${name}"${tenantLabel}. Token refreshed.`);
74+
return;
75+
}
76+
}
77+
printWarning("Token refresh failed. You may need to re-login.");
78+
} catch {
79+
printWarning("Token refresh failed. You may need to re-login.");
80+
}
81+
}
82+
}
83+
84+
printSuccess(`Switched to profile "${name}"${tenantLabel}.`);
4585
});
4686

4787
addExamples(use, [
@@ -108,6 +148,13 @@ export function registerProfileCommands(program: Command): void {
108148
typeof value === "string"
109149
) {
110150
console.log(`${key}: ***`);
151+
} else if (key === "availableTenants" && Array.isArray(value)) {
152+
console.log(`${key}:`);
153+
for (const t of value as { tenantId: string; name?: string; role: string }[]) {
154+
const label = t.name ? `${t.name} (${t.tenantId})` : t.tenantId;
155+
const current = t.tenantId === config.tenantId ? " ← current" : "";
156+
console.log(` - ${label} [${t.role}]${current}`);
157+
}
111158
} else {
112159
console.log(`${key}: ${value}`);
113160
}

src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ function migrateV1ToV2(data: Record<string, unknown>): GdbConfigFile {
2323
const knownKeys = [
2424
"url",
2525
"service",
26+
"tenantId",
2627
"token",
2728
"refreshToken",
2829
"format",

src/prompt.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export async function promptPassword(): Promise<string> {
106106

107107
export interface TenantChoice {
108108
tenantId: string;
109+
name?: string;
109110
role: string;
110111
}
111112

@@ -120,7 +121,8 @@ export async function promptTenantSelection(
120121
const t = tenants[i];
121122
const current = t.tenantId === currentTenantId ? " ← current" : "";
122123
const marker = t.tenantId === currentTenantId ? " *" : " ";
123-
console.log(`${marker} ${i + 1}) ${t.tenantId} (${t.role})${current}`);
124+
const label = t.name ? `${t.name} (${t.tenantId})` : t.tenantId;
125+
console.log(`${marker} ${i + 1}) ${label} [${t.role}]${current}`);
124126
}
125127
for (;;) {
126128
const answer = await rl.question("\nSelect tenant number (Enter to keep current): ");

src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
export type OutputFormat = "json" | "table" | "geojson";
22

3+
export interface TenantInfo {
4+
tenantId: string;
5+
name?: string;
6+
role: string;
7+
}
8+
39
export interface GdbConfig {
410
url?: string;
511
service?: string;
12+
tenantId?: string;
13+
availableTenants?: TenantInfo[];
614
token?: string;
715
refreshToken?: string;
816
format?: OutputFormat;

tests/auth.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,129 @@ describe("auth commands", () => {
491491
"default",
492492
);
493493
});
494+
495+
it("saves tenantId and availableTenants to config", async () => {
496+
const tenants = [
497+
{ tenantId: "city_a", name: "Smart City A", role: "tenant_admin" },
498+
{ tenantId: "city_b", role: "user" },
499+
];
500+
client.rawRequest.mockResolvedValue(
501+
mockResponse({ accessToken: "tok", tenantId: "city_a", availableTenants: tenants }),
502+
);
503+
vi.mocked(promptTenantSelection).mockResolvedValue(undefined);
504+
const program = makeProgram();
505+
await runCommand(program, ["auth", "login"]);
506+
expect(saveConfig).toHaveBeenCalledWith(
507+
expect.objectContaining({
508+
tenantId: "city_a",
509+
availableTenants: [
510+
{ tenantId: "city_a", name: "Smart City A", role: "tenant_admin" },
511+
{ tenantId: "city_b", role: "user" },
512+
],
513+
}),
514+
"default",
515+
);
516+
});
517+
518+
it("resolves tenant by name via --service flag", async () => {
519+
const tenants = [
520+
{ tenantId: "tid-aaa", name: "demo_smartcity", role: "tenant_admin" },
521+
{ tenantId: "tid-bbb", name: "demo_bousai", role: "user" },
522+
];
523+
vi.mocked(resolveOptions).mockReturnValue({
524+
url: "http://localhost:3000",
525+
profile: "default",
526+
token: "test-token",
527+
format: "json",
528+
service: "demo_bousai",
529+
} as never);
530+
client.rawRequest
531+
.mockResolvedValueOnce(
532+
mockResponse({ accessToken: "tok-a", tenantId: "tid-aaa", availableTenants: tenants }),
533+
)
534+
.mockResolvedValueOnce(
535+
mockResponse({ accessToken: "tok-b", refreshToken: "ref-b", tenantId: "tid-bbb" }),
536+
);
537+
const program = makeProgram();
538+
await runCommand(program, ["auth", "login"]);
539+
// Should NOT prompt — resolved by --service name
540+
expect(promptTenantSelection).not.toHaveBeenCalled();
541+
// Should re-login with resolved tenantId
542+
expect(client.rawRequest).toHaveBeenLastCalledWith("POST", "/auth/login", {
543+
body: { email: "user@example.com", password: "pass123", tenantId: "tid-bbb" },
544+
skipTenantHeader: true,
545+
});
546+
expect(saveConfig).toHaveBeenCalledWith(
547+
expect.objectContaining({ token: "tok-b", service: "tid-bbb", tenantId: "tid-bbb" }),
548+
"default",
549+
);
550+
});
551+
552+
it("resolves tenant by tenantId via --service flag", async () => {
553+
const tenants = [
554+
{ tenantId: "city_a", role: "tenant_admin" },
555+
{ tenantId: "city_b", role: "user" },
556+
];
557+
vi.mocked(resolveOptions).mockReturnValue({
558+
url: "http://localhost:3000",
559+
profile: "default",
560+
token: "test-token",
561+
format: "json",
562+
service: "city_b",
563+
} as never);
564+
client.rawRequest
565+
.mockResolvedValueOnce(
566+
mockResponse({ accessToken: "tok-a", tenantId: "city_a", availableTenants: tenants }),
567+
)
568+
.mockResolvedValueOnce(
569+
mockResponse({ accessToken: "tok-b", tenantId: "city_b" }),
570+
);
571+
const program = makeProgram();
572+
await runCommand(program, ["auth", "login"]);
573+
expect(promptTenantSelection).not.toHaveBeenCalled();
574+
expect(saveConfig).toHaveBeenCalledWith(
575+
expect.objectContaining({ token: "tok-b", service: "city_b" }),
576+
"default",
577+
);
578+
});
579+
580+
it("prints error when --service tenant name not found", async () => {
581+
const tenants = [
582+
{ tenantId: "city_a", name: "Smart City A", role: "tenant_admin" },
583+
{ tenantId: "city_b", role: "user" },
584+
];
585+
vi.mocked(resolveOptions).mockReturnValue({
586+
url: "http://localhost:3000",
587+
profile: "default",
588+
token: "test-token",
589+
format: "json",
590+
service: "nonexistent",
591+
} as never);
592+
client.rawRequest.mockResolvedValue(
593+
mockResponse({ accessToken: "tok", tenantId: "city_a", availableTenants: tenants }),
594+
);
595+
const program = makeProgram();
596+
await expect(
597+
runCommand(program, ["auth", "login"]),
598+
).rejects.toThrow("process.exit");
599+
expect(printError).toHaveBeenCalledWith(
600+
expect.stringContaining('Tenant "nonexistent" not found'),
601+
);
602+
});
603+
604+
it("includes tenant label in success message", async () => {
605+
const tenants = [
606+
{ tenantId: "city_a", name: "Smart City A", role: "tenant_admin" },
607+
];
608+
client.rawRequest.mockResolvedValue(
609+
mockResponse({ accessToken: "tok", tenantId: "city_a", availableTenants: tenants }),
610+
);
611+
const program = makeProgram();
612+
await runCommand(program, ["auth", "login"]);
613+
expect(printSuccess).toHaveBeenCalledWith(
614+
"Login successful (tenant: Smart City A). Token saved to config.",
615+
);
616+
});
494617
});
495618

496619
describe("auth logout", () => {

0 commit comments

Comments
 (0)