Commit 4c3930dc by luoqi

feat(ai): unify login and add authorized workspace switching

parent 060c36b6
Pipeline #3728 failed in 0 seconds
import { SetMetadata } from '@nestjs/common'; import { SetMetadata } from '@nestjs/common';
/** 默认接口只允许客户会话;仅显式标注的接口可接收平台会话。 */ /** 默认接口只允许客户会话;仅显式标注的接口可接收平台会话。 */
export const AUTH_SCOPE = 'authScope'; export const AUTH_SCOPE = 'authScope';
export const AuthScope = (scope: 'tenant' | 'platform') => SetMetadata(AUTH_SCOPE, scope); export const AuthScope = (scope: 'tenant' | 'platform' | 'account') => SetMetadata(AUTH_SCOPE, scope);
...@@ -18,6 +18,8 @@ import { REQUIRE_PERMISSION } from '../decorators/require-permission.decorator'; ...@@ -18,6 +18,8 @@ import { REQUIRE_PERMISSION } from '../decorators/require-permission.decorator';
import { isReadOnly, permissionsOf, type Permission } from '../permissions'; import { isReadOnly, permissionsOf, type Permission } from '../permissions';
import { ALLOW_VIEWER_WRITE } from '../decorators/viewer-write.decorator'; import { ALLOW_VIEWER_WRITE } from '../decorators/viewer-write.decorator';
export interface AccountUser { id: string; sessionId: string; tokenVersion: number; }
export interface PlatformUser { id: string; displayName: string; role: string; permissions: readonly Permission[]; sessionId: string; mustChangePassword: boolean; } export interface PlatformUser { id: string; displayName: string; role: string; permissions: readonly Permission[]; sessionId: string; mustChangePassword: boolean; }
export interface AuthUser { export interface AuthUser {
...@@ -71,7 +73,7 @@ export class JwtAuthGuard implements CanActivate { ...@@ -71,7 +73,7 @@ export class JwtAuthGuard implements CanActivate {
]); ]);
if (isPublic) return true; if (isPublic) return true;
const req = ctx.switchToHttp().getRequest<Request & { user?: AuthUser; platformUser?: PlatformUser }>(); const req = ctx.switchToHttp().getRequest<Request & { user?: AuthUser; platformUser?: PlatformUser; accountUser?: AccountUser }>();
const token = extractToken(req); const token = extractToken(req);
if (!token) throw new UnauthorizedException('请先登录'); if (!token) throw new UnauthorizedException('请先登录');
...@@ -108,7 +110,7 @@ export class JwtAuthGuard implements CanActivate { ...@@ -108,7 +110,7 @@ export class JwtAuthGuard implements CanActivate {
// ⚠️ 三个条件缺一不可,而且**每一个都对应一种真实的撤销需求** // ⚠️ 三个条件缺一不可,而且**每一个都对应一种真实的撤销需求**
if (!user) throw new UnauthorizedException('账号不存在,请重新登录'); if (!user) throw new UnauthorizedException('账号不存在,请重新登录');
const scope = this.reflector.getAllAndOverride<string>(AUTH_SCOPE, [ctx.getHandler(), ctx.getClass()]) ?? 'tenant'; const scope = this.reflector.getAllAndOverride<string>(AUTH_SCOPE, [ctx.getHandler(), ctx.getClass()]) ?? 'tenant';
if (session.scope !== scope) throw new NotFoundException('当前会话不能访问此工作区'); if (scope !== 'account' && session.scope !== scope) throw new NotFoundException('当前会话不能访问此工作区');
const grant = await this.prisma.userRoleGrant.findUnique({ where: { userId_scope: { userId: user.id, scope: session.scope } } }); const grant = await this.prisma.userRoleGrant.findUnique({ where: { userId_scope: { userId: user.id, scope: session.scope } } });
if (!grant || (session.scope === 'tenant' && grant.tenantId !== user.tenantId)) throw new UnauthorizedException('授权已撤销'); if (!grant || (session.scope === 'tenant' && grant.tenantId !== user.tenantId)) throw new UnauthorizedException('授权已撤销');
if (session.scope === 'tenant' && (!user.tenantId || user.tenantId !== session.tenantId || user.tenant?.status !== 'active')) if (session.scope === 'tenant' && (!user.tenantId || user.tenantId !== session.tenantId || user.tenant?.status !== 'active'))
...@@ -179,6 +181,13 @@ export class JwtAuthGuard implements CanActivate { ...@@ -179,6 +181,13 @@ export class JwtAuthGuard implements CanActivate {
if (!allowed) throw new NotFoundException('请求的功能不存在,请刷新页面后再试'); if (!allowed) throw new NotFoundException('请求的功能不存在,请刷新页面后再试');
} }
// 账号级接口不注入租户业务身份;宿主会话不能通过切换扩大授权范围。
if (scope === 'account') {
if (session.source !== 'password' || session.hostScope || !['tenant', 'platform'].includes(session.scope))
throw new UnauthorizedException('切换工作台需要密码登录');
req.accountUser = { id: user.id, sessionId: session.id, tokenVersion: user.tokenVersion };
return true;
}
if (session.scope === 'platform') { if (session.scope === 'platform') {
if (session.source !== 'password' || session.hostScope) throw new UnauthorizedException('平台管理需要密码登录'); if (session.source !== 'password' || session.hostScope) throw new UnauthorizedException('平台管理需要密码登录');
req.platformUser = { id: user.id, displayName: user.displayName, role: grant.role, permissions, sessionId: session.id, mustChangePassword: user.mustChangePassword }; req.platformUser = { id: user.id, displayName: user.displayName, role: grant.role, permissions, sessionId: session.id, mustChangePassword: user.mustChangePassword };
......
import { AuthScope } from '../../common/decorators/auth-scope.decorator';
import type { AccountUser } from '../../common/guards/jwt-auth.guard';
import { HostingSeatService } from '../hosting/hosting-seat.service'; import { HostingSeatService } from '../hosting/hosting-seat.service';
import { IntegrationAccessService } from '../integration/integration-access.service'; import { IntegrationAccessService } from '../integration/integration-access.service';
import { SessionService } from './session.service'; import { SessionService } from './session.service';
...@@ -73,7 +75,10 @@ export class AuthController { ...@@ -73,7 +75,10 @@ export class AuthController {
) { ) {
if (body.scope && !['tenant', 'platform'].includes(body.scope)) throw new BadRequestException('登录范围无效'); if (body.scope && !['tenant', 'platform'].includes(body.scope)) throw new BadRequestException('登录范围无效');
const r = await this.passwords.login(String(body.phone ?? ''), String(body.password ?? ''), body.scope ?? 'tenant'); const r = await this.passwords.login(String(body.phone ?? ''), String(body.password ?? ''), body.scope ?? 'tenant');
const tokens = await this.sessions.create(r.userId, 'password', r.tokenVersion, body.scope ?? 'tenant'); const workspaces = await this.sessions.workspaces(r.userId);
const scope = body.scope ?? workspaces[0];
if (!scope || !workspaces.includes(scope)) throw new UnauthorizedException('没有可访问的工作台');
const tokens = await this.sessions.create(r.userId, 'password', r.tokenVersion, scope);
res.clearCookie(AUTH_COOKIE, { path: '/' }); res.clearCookie(AUTH_COOKIE, { path: '/' });
/** /**
* ⭐ 把这个来源记成"熟客" —— 全局限流闸对它豁免。 * ⭐ 把这个来源记成"熟客" —— 全局限流闸对它豁免。
...@@ -85,6 +90,7 @@ export class AuthController { ...@@ -85,6 +90,7 @@ export class AuthController {
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
return { return {
...tokens, ...tokens,
workspaces,
displayName: r.displayName, displayName: r.displayName,
tenantName: r.tenantName, tenantName: r.tenantName,
// ⚠️ 临时密码登录也**照样发令牌** —— 否则他连改密码的接口都调不了。 // ⚠️ 临时密码登录也**照样发令牌** —— 否则他连改密码的接口都调不了。
...@@ -93,6 +99,25 @@ export class AuthController { ...@@ -93,6 +99,25 @@ export class AuthController {
}; };
} }
@AuthScope('account')
@Get('workspaces')
async workspaces(@Req() req: Request & { accountUser: AccountUser }) {
return { workspaces: await this.sessions.workspaces(req.accountUser.id) };
}
@AuthScope('account')
@AllowViewerWrite()
@Post('workspace')
@HttpCode(200)
async switchWorkspace(
@Req() req: Request & { accountUser: AccountUser },
@Body() body: { scope?: 'tenant' | 'platform' },
) {
if (!body?.scope || !['tenant', 'platform'].includes(body.scope)) throw new BadRequestException('工作台无效');
const user = req.accountUser;
return this.sessions.switchWorkspace(user.id, user.sessionId, user.tokenVersion, body.scope);
}
/** /**
* ⭐⭐ 开户激活 —— 管理员开了号之后,本人凭一次性链接**自己**设手机号和密码。 * ⭐⭐ 开户激活 —— 管理员开了号之后,本人凭一次性链接**自己**设手机号和密码。
* *
......
...@@ -48,6 +48,7 @@ export class SessionService { ...@@ -48,6 +48,7 @@ export class SessionService {
source: 'password' | 'activation' | 'development' = 'password', source: 'password' | 'activation' | 'development' = 'password',
expectedVersion?: number, expectedVersion?: number,
scope: 'tenant' | 'platform' = 'tenant', scope: 'tenant' | 'platform' = 'tenant',
expiresAtLimit?: Date,
): Promise<SessionTokens> { ): Promise<SessionTokens> {
const user = await this.prisma.user.findUniqueOrThrow({ const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId }, where: { id: userId },
...@@ -67,7 +68,8 @@ export class SessionService { ...@@ -67,7 +68,8 @@ export class SessionService {
const grant = await this.prisma.userRoleGrant.findUnique({ where: { userId_scope: { userId, scope } } }); const grant = await this.prisma.userRoleGrant.findUnique({ where: { userId_scope: { userId, scope } } });
if (!grant || (scope === 'tenant' && grant.tenantId !== user.tenantId)) throw new UnauthorizedException('没有该工作区的访问权限'); if (!grant || (scope === 'tenant' && grant.tenantId !== user.tenantId)) throw new UnauthorizedException('没有该工作区的访问权限');
const now = new Date(); const now = new Date();
const expiresAt = new Date(now.getTime() + this.absoluteSeconds * 1000); const expiresAt = new Date(Math.min(now.getTime() + this.absoluteSeconds * 1000, expiresAtLimit?.getTime() ?? Infinity));
if (expiresAt <= now) throw new UnauthorizedException('登录已过期,请重新登录');
const id = randomUUID(); const id = randomUUID();
const nonce = randomUUID(); const nonce = randomUUID();
const refreshToken = this.jwt.signRefresh( const refreshToken = this.jwt.signRefresh(
...@@ -98,6 +100,30 @@ export class SessionService { ...@@ -98,6 +100,30 @@ export class SessionService {
return this.tokensFor(row, refreshToken); return this.tokensFor(row, refreshToken);
} }
/** 同一账号当前可进入的工作台,以有效授权与客户状态为准。 */
async workspaces(userId: string): Promise<Array<'tenant' | 'platform'>> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { tenantId: true, isActive: true, tenant: { select: { status: true } } },
});
if (!user?.isActive) throw new UnauthorizedException('账号已停用');
const grants = await this.prisma.userRoleGrant.findMany({ where: { userId } });
const result: Array<'tenant' | 'platform'> = [];
if (user.tenantId && user.tenant?.status === 'active' && grants.some(g => g.scope === 'tenant' && g.tenantId === user.tenantId)) result.push('tenant');
if (grants.some(g => g.scope === 'platform' && g.tenantId === null)) result.push('platform');
return result;
}
async switchWorkspace(userId: string, sessionId: string, version: number, scope: 'tenant' | 'platform') {
const session = await this.active(sessionId, userId, version);
if (!session || session.source !== 'password' || session.hostScope)
throw new UnauthorizedException('切换工作台需要密码登录');
if (!(await this.workspaces(userId)).includes(scope))
throw new UnauthorizedException('没有该工作台的访问权限');
// 新窗口会话仍可继续使用;切换不延长这次密码登录的绝对有效期。
return this.create(userId, 'password', version, scope, session.expiresAt);
}
/** 行锁串行轮换;相同 requestId 的短期重试不被误判成重放。 */ /** 行锁串行轮换;相同 requestId 的短期重试不被误判成重放。 */
async refresh(token: string, requestId: string): Promise<SessionTokens> { async refresh(token: string, requestId: string): Promise<SessionTokens> {
const claims = this.jwt.verify(token, 'refresh'); const claims = this.jwt.verify(token, 'refresh');
......
...@@ -270,4 +270,56 @@ run('统一用户与平台 RBAC(现有本地数据库)', () => { ...@@ -270,4 +270,56 @@ run('统一用户与平台 RBAC(现有本地数据库)', () => {
), ),
).rejects.toThrow(); ).rejects.toThrow();
}); });
it('统一入口识别有效工作台,停用客户不影响平台身份', async () => {
expect(await sessions.workspaces(userId)).toEqual(['tenant']);
expect(await sessions.workspaces(platformId)).toEqual(['platform']);
await db.userRoleGrant.create({ data: { userId, scope: 'platform', role: 'platform_admin' } });
try {
expect(await sessions.workspaces(userId)).toEqual(['tenant', 'platform']);
await db.tenant.update({ where: { id: tenantId }, data: { status: 'suspended' } });
expect(await sessions.workspaces(userId)).toEqual(['platform']);
} finally {
await db.tenant.update({ where: { id: tenantId }, data: { status: 'active' } });
await db.userRoleGrant.delete({ where: { userId_scope: { userId, scope: 'platform' } } });
}
});
it('账号级守卫不注入业务身份,拒绝临时密码与非密码会话', async () => {
const token = await sessions.create(userId);
const req = await authorize(token, 'account');
expect(req.accountUser.id).toBe(userId);
expect(req.user).toBeUndefined();
expect(req.platformUser).toBeUndefined();
const platform = await sessions.create(platformId, 'password', 0, 'platform');
expect((await authorize(platform, 'account')).accountUser.id).toBe(platformId);
for (const source of ['activation', 'development'] as const) {
const denied = await sessions.create(userId, source);
await expect(authorize(denied, 'account')).rejects.toThrow();
await expect(sessions.switchWorkspace(userId, denied.sessionId, 0, 'tenant')).rejects.toThrow();
}
await db.user.update({ where: { id: userId }, data: { mustChangePassword: true } });
try { await expect(authorize(token, 'account')).rejects.toThrow('请先修改初始密码'); }
finally { await db.user.update({ where: { id: userId }, data: { mustChangePassword: false } }); }
});
it('切换重新授权,保留隔离与原登录绝对期限', async () => {
const tenant = await sessions.create(userId);
await expect(sessions.switchWorkspace(userId, tenant.sessionId, 0, 'platform')).rejects.toThrow();
await db.userRoleGrant.create({ data: { userId, scope: 'platform', role: 'platform_viewer' } });
try {
const platform = await sessions.switchWorkspace(userId, tenant.sessionId, 0, 'platform');
expect(platform.scope).toBe('platform');
expect(platform.tenantId).toBeNull();
expect((await db.authSession.findUniqueOrThrow({ where: { id: platform.sessionId } })).expiresAt).toEqual((await db.authSession.findUniqueOrThrow({ where: { id: tenant.sessionId } })).expiresAt);
await expect(authorize(platform)).rejects.toThrow();
const back = await sessions.switchWorkspace(userId, platform.sessionId, 0, 'tenant');
expect(back.tenantId).toBe(tenantId);
expect((await db.authSession.findUniqueOrThrow({ where: { id: back.sessionId } })).expiresAt).toEqual((await db.authSession.findUniqueOrThrow({ where: { id: tenant.sessionId } })).expiresAt);
await expect(authorize(back, 'platform')).rejects.toThrow();
} finally {
await db.userRoleGrant.delete({ where: { userId_scope: { userId, scope: 'platform' } } });
}
await expect(sessions.switchWorkspace(userId, tenant.sessionId, 0, 'platform')).rejects.toThrow();
await sessions.revoke(tenant.sessionId, userId);
await expect(sessions.switchWorkspace(userId, tenant.sessionId, 0, 'tenant')).rejects.toThrow();
});
}); });
import { AuthController } from '../src/modules/auth/auth.controller';
function fixture(workspaces: Array<'tenant' | 'platform'>) {
const controller = Object.create(AuthController.prototype) as AuthController;
const sessions = {
workspaces: jest.fn(async () => workspaces),
create: jest.fn(async (_id, _source, _version, scope) => ({ scope })),
};
Object.assign(controller, {
sessions,
passwords: {
login: jest.fn(async () => ({ userId: 'user', tokenVersion: 3, mustChangePassword: true })),
},
throttle: { noteSuccess: jest.fn() },
});
const login = (scope?: 'tenant' | 'platform') =>
controller.login(
{ phone: '19999999999', password: 'test', scope },
{ ip: '127.0.0.1' } as any,
{ clearCookie: jest.fn(), setHeader: jest.fn() } as any,
);
return { login, sessions };
}
describe('统一登录入口', () => {
it.each(['tenant', 'platform'] as const)('单一 %s 身份自动选择', async (scope) => {
const f = fixture([scope]);
expect(await f.login()).toMatchObject({ scope, workspaces: [scope], mustChangePassword: true });
});
it('双重身份返回全部工作台,选择前不授予混合会话', async () => {
const f = fixture(['tenant', 'platform']);
expect(await f.login()).toMatchObject({ scope: 'tenant', workspaces: ['tenant', 'platform'] });
expect(f.sessions.create).toHaveBeenCalledWith('user', 'password', 3, 'tenant');
});
it('没有有效授权不签发会话', async () => {
const f = fixture([]);
await expect(f.login()).rejects.toThrow('没有可访问的工作台');
expect(f.sessions.create).not.toHaveBeenCalled();
});
it('不能请求未经授予的身份', async () => {
const f = fixture(['tenant']);
await expect(f.login('platform')).rejects.toThrow();
expect(f.sessions.create).not.toHaveBeenCalled();
});
});
...@@ -155,5 +155,5 @@ it('平台账号连续刷新页面仍保留平台范围和空租户', async () = ...@@ -155,5 +155,5 @@ it('平台账号连续刷新页面仍保留平台范围和空租户', async () =
expect(await load().accessToken()).toBe(platform.accessToken); expect(await load().accessToken()).toBe(platform.accessToken);
expect(JSON.parse(storage.getItem('friday.auth.session.' + a)!)).toMatchObject({ scope: 'platform', tenantId: null }); expect(JSON.parse(storage.getItem('friday.auth.session.' + a)!)).toMatchObject({ scope: 'platform', tenantId: null });
Object.assign(fakeWindow, { location: { href: 'http://localhost:3103/platform', pathname: '/platform' } }); Object.assign(fakeWindow, { location: { href: 'http://localhost:3103/platform', pathname: '/platform' } });
expect(load().loginDestination()).toBe('/platform/login'); expect(load().loginDestination()).toBe('/login');
}); });
import { LoginForm } from '@/components/login-form';
export default function PlatformLoginPage() {
return <LoginForm platform />;
}
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { WorkspaceSwitch } from '@/components/workspace-switch';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
...@@ -88,7 +89,7 @@ export default function PlatformPage() { ...@@ -88,7 +89,7 @@ export default function PlatformPage() {
const m = await api.get<Me>('/platform/me'); const m = await api.get<Me>('/platform/me');
if (!alive) return; if (!alive) return;
if (m.mustChangePassword) { if (m.mustChangePassword) {
router.replace('/platform/login'); router.replace('/login');
return; return;
} }
setMe(m); setMe(m);
...@@ -154,10 +155,11 @@ export default function PlatformPage() { ...@@ -154,10 +155,11 @@ export default function PlatformPage() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span>{me?.displayName}</span> <span>{me?.displayName}</span>
{me && <WorkspaceSwitch />}
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onClick={() =>
void api.post('/auth/logout', {}).then(() => router.replace('/platform/login')) void api.post('/auth/logout', {}).then(() => router.replace('/login'))
} }
> >
退出 退出
...@@ -195,7 +197,7 @@ export default function PlatformPage() { ...@@ -195,7 +197,7 @@ export default function PlatformPage() {
<div role="alert" className="rounded-xl bg-rose-50 p-4 text-rose-700"> <div role="alert" className="rounded-xl bg-rose-50 p-4 text-rose-700">
{error}{' '} {error}{' '}
{!me ? ( {!me ? (
<Button variant="outline" onClick={() => router.replace('/platform/login')}> <Button variant="outline" onClick={() => router.replace('/login')}>
前往平台登录 前往平台登录
</Button> </Button>
) : null} ) : null}
......
'use client';
import { useEffect, useState } from 'react';
import { api, ApiError } from '@/lib/api';
import { Button } from '@/components/ui/button';
type Workspace = 'tenant' | 'platform';
const destinations = { tenant: '/', platform: '/platform' };
const labels = { tenant: '客户工作台', platform: '平台管理' };
export default function WorkspacesPage() {
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<Workspace | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.get<{ workspaces: Workspace[] }>('/auth/workspaces')
.then((r) => {
if (!cancelled) setWorkspaces(r.workspaces);
})
.catch((e) => {
if (!cancelled) setError(e instanceof ApiError ? e.message : '暂时无法读取工作台,请重试');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
async function enter(scope: Workspace) {
if (busy) return;
setBusy(scope);
setError(null);
try {
await api.post('/auth/workspace', { scope });
// 整页进入,让上一工作台的患者缓存和组件状态随页面释放。
window.location.replace(destinations[scope]);
} catch (e) {
setError(e instanceof ApiError ? e.message : '暂时无法进入,请重试');
setBusy(null);
}
}
return (
<main className="flex min-h-screen items-center justify-center px-5 py-10">
<section className="w-full max-w-[340px] rounded-[14px] bg-white p-[22px]">
<h1 className="text-[22px] font-semibold tracking-[-.4px]">选择工作台</h1>
<p className="mt-2 text-[12.5px] text-slate-500">请选择本次要进入的工作台。</p>
<div className="mt-5 flex flex-col gap-3">
{loading && (
<p role="status" className="text-[12.5px] text-slate-500">
正在读取…
</p>
)}
{workspaces.map((scope) => (
<Button key={scope} size="lg" disabled={!!busy} onClick={() => void enter(scope)}>
{busy === scope ? '正在进入…' : labels[scope]}
</Button>
))}
{!loading && !workspaces.length && !error && (
<p className="text-[12.5px] text-slate-500">暂无可访问的工作台</p>
)}
{error && (
<p role="alert" className="text-[12.5px] text-rose-700">
{error}
</p>
)}
<Button
variant="ghost"
disabled={!!busy}
onClick={() =>
void api.post('/auth/logout', {}).then(() => window.location.replace('/login'))
}
>
退出登录
</Button>
</div>
</section>
</main>
);
}
...@@ -9,7 +9,7 @@ export function AuthSessionBoundary({ children }: { children: ReactNode }) { ...@@ -9,7 +9,7 @@ export function AuthSessionBoundary({ children }: { children: ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => { useEffect(() => {
const changed = () => { const changed = () => {
if (!currentSessionId() && pathname !== '/platform/login' && pathname !== '/login' && pathname !== '/activate' && pathname !== '/embed') if (!currentSessionId() && pathname !== '/login' && pathname !== '/activate' && pathname !== '/embed')
router.replace(loginDestination()); router.replace(loginDestination());
}; };
persistWindowSelection(); persistWindowSelection();
......
...@@ -28,8 +28,9 @@ import { Input } from '@/components/ui/input'; ...@@ -28,8 +28,9 @@ import { Input } from '@/components/ui/input';
*/ */
type Mode = 'login' | 'change'; type Mode = 'login' | 'change';
export function LoginForm({ platform = false }: { platform?: boolean }) { export function LoginForm() {
const router = useRouter(); const router = useRouter();
const [scope, setScope] = useState<'tenant' | 'platform'>('tenant');
const [mode, setMode] = useState<Mode>('login'); const [mode, setMode] = useState<Mode>('login');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
...@@ -71,14 +72,14 @@ export function LoginForm({ platform = false }: { platform?: boolean }) { ...@@ -71,14 +72,14 @@ export function LoginForm({ platform = false }: { platform?: boolean }) {
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
const r = await api.post<{ displayName: string; mustChangePassword: boolean }>( const r = await api.post<{ displayName: string; mustChangePassword: boolean; scope: 'tenant' | 'platform'; workspaces: string[] }>(
'/auth/login', '/auth/login',
{ {
phone: phoneNow, phone: phoneNow,
password: passwordNow, password: passwordNow,
scope: platform ? 'platform' : 'tenant',
}, },
); );
setScope(r.scope);
if (r.mustChangePassword) { if (r.mustChangePassword) {
// ⚠️ 会话已经保存了,但后端守卫只放行 /auth/me 和 /auth/password。 // ⚠️ 会话已经保存了,但后端守卫只放行 /auth/me 和 /auth/password。
// ⛔ 别在这儿 router.push('/'):会立刻 401 弹回来,白闪一下。 // ⛔ 别在这儿 router.push('/'):会立刻 401 弹回来,白闪一下。
...@@ -89,14 +90,14 @@ export function LoginForm({ platform = false }: { platform?: boolean }) { ...@@ -89,14 +90,14 @@ export function LoginForm({ platform = false }: { platform?: boolean }) {
} }
// ⭐ 标一下「这次是新登录」—— 补资料的弹窗只在这种时候弹,⛔ 不是每次刷新 // ⭐ 标一下「这次是新登录」—— 补资料的弹窗只在这种时候弹,⛔ 不是每次刷新
markJustLoggedIn(); markJustLoggedIn();
router.replace(platform ? '/platform' : '/'); router.replace(r.workspaces.length > 1 ? '/workspaces' : r.scope === 'platform' ? '/platform' : '/');
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : '登录失败,请稍后再试'); setError(e instanceof ApiError ? e.message : '登录失败,请稍后再试');
setPassword(''); setPassword('');
} finally { } finally {
setBusy(false); setBusy(false);
} }
}, [busy, phone, password, router, platform]); }, [busy, phone, password, router]);
const submitChange = useCallback(async () => { const submitChange = useCallback(async () => {
if (busy) return; if (busy) return;
...@@ -107,7 +108,7 @@ export function LoginForm({ platform = false }: { platform?: boolean }) { ...@@ -107,7 +108,7 @@ export function LoginForm({ platform = false }: { platform?: boolean }) {
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
await api.post(platform ? '/platform/password' : '/auth/password', { await api.post(scope === 'platform' ? '/platform/password' : '/auth/password', {
oldPassword: password, oldPassword: password,
newPassword: next1, newPassword: next1,
}); });
...@@ -126,7 +127,7 @@ export function LoginForm({ platform = false }: { platform?: boolean }) { ...@@ -126,7 +127,7 @@ export function LoginForm({ platform = false }: { platform?: boolean }) {
} finally { } finally {
setBusy(false); setBusy(false);
} }
}, [busy, next1, next2, password, platform]); }, [busy, next1, next2, password, scope]);
return ( return (
<main className="flex min-h-screen items-center justify-center px-5 py-10 sm:px-8"> <main className="flex min-h-screen items-center justify-center px-5 py-10 sm:px-8">
...@@ -142,9 +143,7 @@ export function LoginForm({ platform = false }: { platform?: boolean }) { ...@@ -142,9 +143,7 @@ export function LoginForm({ platform = false }: { platform?: boolean }) {
<> <>
<h1 className="text-[22px] font-semibold tracking-[-.4px]">登录</h1> <h1 className="text-[22px] font-semibold tracking-[-.4px]">登录</h1>
<p className="mt-[7px] text-[12.5px] leading-[1.7] text-slate-500 text-pretty"> <p className="mt-[7px] text-[12.5px] leading-[1.7] text-slate-500 text-pretty">
{platform 用管理员给你的手机号和密码登录。
? '使用平台管理账号登录。'
: '用管理员给你的手机号和密码登录。登录后再绑定你的企业微信号。'}
</p> </p>
<form <form
......
...@@ -5,6 +5,7 @@ import { useState } from 'react'; ...@@ -5,6 +5,7 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { ADMIN_PERMS, can, PERM, type ClinicOption, type Me } from '@/lib/api'; import { ADMIN_PERMS, can, PERM, type ClinicOption, type Me } from '@/lib/api';
import { WorkspaceSwitch } from '@/components/workspace-switch';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { MyProfileDialog } from '@/components/my-profile-dialog'; import { MyProfileDialog } from '@/components/my-profile-dialog';
import { withInactive } from '@/lib/labels'; import { withInactive } from '@/lib/labels';
...@@ -268,6 +269,7 @@ export function Sidebar({ ...@@ -268,6 +269,7 @@ export function Sidebar({
); );
})} })}
</nav> </nav>
{me && !me.hostContext && <WorkspaceSwitch confirmLeave={confirmLeave} />}
{/* 稿子里是一张圆角灰卡,不是一条分隔线 */} {/* 稿子里是一张圆角灰卡,不是一条分隔线 */}
{/* ⚠️ 未绑定时整张卡染色 —— 侧栏底部是账号动作的固定位置(⏻ 一直在这儿), {/* ⚠️ 未绑定时整张卡染色 —— 侧栏底部是账号动作的固定位置(⏻ 一直在这儿),
......
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { api } from '@/lib/api';
import { currentHostContext } from '@/lib/auth-session';
import { Button } from '@/components/ui/button';
/** 仅双重身份显示;宿主内嵌保持宿主授予的范围。 */
export function WorkspaceSwitch({ confirmLeave }: { confirmLeave?: () => boolean }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (currentHostContext()) return;
let cancelled = false;
api
.get<{ workspaces: string[] }>('/auth/workspaces')
.then((r) => {
if (!cancelled) setVisible(r.workspaces.length > 1);
})
.catch(() => {
if (!cancelled) setVisible(false);
});
return () => {
cancelled = true;
};
}, []);
if (!visible) return null;
return (
<Button variant="ghost" asChild>
<Link
href="/workspaces"
onClick={(event) => {
if (confirmLeave && !confirmLeave()) event.preventDefault();
}}
>
切换工作台
</Link>
</Button>
);
}
...@@ -55,7 +55,7 @@ async function call<T>(path: string, init?: RequestInit): Promise<T> { ...@@ -55,7 +55,7 @@ async function call<T>(path: string, init?: RequestInit): Promise<T> {
if (res.status >= 500) throw new ApiError(-1, `ai-service ${res.status}`); if (res.status >= 500) throw new ApiError(-1, `ai-service ${res.status}`);
const body = (await res.json()) as Envelope<T>; const body = (await res.json()) as Envelope<T>;
if (body.code !== CODE_OK) throw new ApiError(body.code, body.msg, body.details); if (body.code !== CODE_OK) throw new ApiError(body.code, body.msg, body.details);
if (entering) await installSession(body.data as T & SessionTokens); if (entering || (path === '/auth/workspace' && init?.method === 'POST')) await installSession(body.data as T & SessionTokens);
if (path === '/auth/password' || path === '/platform/password') clearSession(); if (path === '/auth/password' || path === '/platform/password') clearSession();
return body.data as T; return body.data as T;
} catch (e) { } catch (e) {
......
...@@ -78,8 +78,7 @@ export function persistWindowSelection(): void { ...@@ -78,8 +78,7 @@ export function persistWindowSelection(): void {
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (url.searchParams.get('sid') !== id) { url.searchParams.set('sid', id); window.history.replaceState(window.history.state, '', url); } if (url.searchParams.get('sid') !== id) { url.searchParams.set('sid', id); window.history.replaceState(window.history.state, '', url); }
} }
export function loginDestination(): '/embed' | '/login' | '/platform/login' { export function loginDestination(): '/embed' | '/login' {
if (typeof window !== 'undefined' && window.location?.pathname?.startsWith('/platform')) return '/platform/login';
// 宿主窗口的 sid 不是凭据;授权被别的窗口清掉后仍能辨认这个入口。 // 宿主窗口的 sid 不是凭据;授权被别的窗口清掉后仍能辨认这个入口。
const hostWindow = typeof window !== 'undefined' && window.location?.href const hostWindow = typeof window !== 'undefined' && window.location?.href
&& new URL(window.location.href).searchParams.has('sid'); && new URL(window.location.href).searchParams.has('sid');
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
## 使用 ## 使用
页面:`/platform/login` → `/platform`。与客户工作台使用同一域名、同一服务。 统一登录页:`/login`。仅客户身份进入 `/`,仅平台身份进入 `/platform`,同时拥有两种身份时进入 `/workspaces` 选择,并可在工作台切换。权限和数据范围按当前会话隔离,宿主内嵌会话不提供切换。
平台管理首期提供: 平台管理首期提供:
...@@ -76,7 +76,7 @@ pnpm --filter @pac/ai-service ai:platform revoke \ ...@@ -76,7 +76,7 @@ pnpm --filter @pac/ai-service ai:platform revoke \
2. 备份 AI 数据库,构建与迁移版本一致的后端、前端产物。 2. 备份 AI 数据库,构建与迁移版本一致的后端、前端产物。
3. 停止旧 AI 服务,运行 `prisma migrate deploy`,再启动新版本。 3. 停止旧 AI 服务,运行 `prisma migrate deploy`,再启动新版本。
4. 迁移包括 `20261009150000_hosting_seats` 与 `20261009190000_platform_rbac`。 4. 迁移包括 `20261009150000_hosting_seats` 与 `20261009190000_platform_rbac`。
5. CLI 开通首名平台管理员,访问 `/platform/login`,改密后验证席位与审计页面。 5. CLI 开通首名平台管理员,访问 `/login`,改密后验证席位与审计页面。
6. 验证原客户登录、宿主内嵌、角色调整和通讯录功能。 6. 验证原客户登录、宿主内嵌、角色调整和通讯录功能。
不能迁移后只回滚旧代码:旧版角色更新会被投影保护触发器拒绝,旧版也不认识纯平台用户的空客户字段。失败时保持维护状态,修复后重启;如需数据库回滚,先备份故障现场,再使用部署前备份并核对期间写入。 不能迁移后只回滚旧代码:旧版角色更新会被投影保护触发器拒绝,旧版也不认识纯平台用户的空客户字段。失败时保持维护状态,修复后重启;如需数据库回滚,先备份故障现场,再使用部署前备份并核对期间写入。
......
...@@ -197,6 +197,6 @@ docker compose -p pac-ai -f docker-compose.ai.yml exec ai-service \ ...@@ -197,6 +197,6 @@ docker compose -p pac-ai -f docker-compose.ai.yml exec ai-service \
--phone='<未使用的手机号>' --name='<姓名>' --reason='平台管理员开户' --phone='<未使用的手机号>' --name='<姓名>' --reason='平台管理员开户'
``` ```
私密保存命令输出的临时密码,打开 `https://fai.jarvismedical.asia/platform/login` 登录并改密。只读账号增加 `--role=platform_viewer`。 私密保存命令输出的临时密码,打开 `https://fai.jarvismedical.asia/login` 登录并改密。只读账号增加 `--role=platform_viewer`。
已有账号使用 `grant --user='<用户UUID>' --role=platform_admin --reason='开通平台管理'`,继续使用原密码。完整命令见 [平台管理](friday-ai-platform-management.md)。 已有账号使用 `grant --user='<用户UUID>' --role=platform_admin --reason='开通平台管理'`,继续使用原密码。完整命令见 [平台管理](friday-ai-platform-management.md)。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment