Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
4c3930dc
Commit
4c3930dc
authored
Oct 09, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(ai): unify login and add authorized workspace switching
parent
060c36b6
Pipeline
#3728
failed in 0 seconds
Changes
18
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
18 changed files
with
310 additions
and
30 deletions
+310
-30
apps/ai-service/src/common/decorators/auth-scope.decorator.ts
+1
-1
apps/ai-service/src/common/guards/jwt-auth.guard.ts
+11
-2
apps/ai-service/src/modules/auth/auth.controller.ts
+26
-1
apps/ai-service/src/modules/auth/session.service.ts
+27
-1
apps/ai-service/tests/platform-rbac.integration.spec.ts
+52
-0
apps/ai-service/tests/unified-login.spec.ts
+45
-0
apps/ai-service/tests/web-auth-session.spec.ts
+1
-1
apps/ai-web/src/app/platform/login/page.tsx
+0
-4
apps/ai-web/src/app/platform/page.tsx
+5
-3
apps/ai-web/src/app/workspaces/page.tsx
+85
-0
apps/ai-web/src/components/auth-session-boundary.tsx
+1
-1
apps/ai-web/src/components/login-form.tsx
+9
-10
apps/ai-web/src/components/sidebar.tsx
+2
-0
apps/ai-web/src/components/workspace-switch.tsx
+40
-0
apps/ai-web/src/lib/api.ts
+1
-1
apps/ai-web/src/lib/auth-session.ts
+1
-2
docs/friday-ai-platform-management.md
+2
-2
docs/friday-ai-test-deployment.md
+1
-1
No files found.
apps/ai-service/src/common/decorators/auth-scope.decorator.ts
View file @
4c3930dc
import
{
SetMetadata
}
from
'@nestjs/common'
;
/** 默认接口只允许客户会话;仅显式标注的接口可接收平台会话。 */
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
);
apps/ai-service/src/common/guards/jwt-auth.guard.ts
View file @
4c3930dc
...
...
@@ -18,6 +18,8 @@ import { REQUIRE_PERMISSION } from '../decorators/require-permission.decorator';
import
{
isReadOnly
,
permissionsOf
,
type
Permission
}
from
'../permissions'
;
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
AuthUser
{
...
...
@@ -71,7 +73,7 @@ export class JwtAuthGuard implements CanActivate {
]);
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
);
if
(
!
token
)
throw
new
UnauthorizedException
(
'请先登录'
);
...
...
@@ -108,7 +110,7 @@ export class JwtAuthGuard implements CanActivate {
// ⚠️ 三个条件缺一不可,而且**每一个都对应一种真实的撤销需求**
if
(
!
user
)
throw
new
UnauthorizedException
(
'账号不存在,请重新登录'
);
const
scope
=
this
.
reflector
.
getAllAndOverride
<
string
>
(
AUTH_SCOPE
,
[
ctx
.
getHandler
(),
ctx
.
getClass
()])
??
'tenant'
;
if
(
session
.
scope
!==
scope
)
throw
new
NotFoundException
(
'当前会话不能访问此工作区'
);
if
(
s
cope
!==
'account'
&&
s
ession
.
scope
!==
scope
)
throw
new
NotFoundException
(
'当前会话不能访问此工作区'
);
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
(
session
.
scope
===
'tenant'
&&
(
!
user
.
tenantId
||
user
.
tenantId
!==
session
.
tenantId
||
user
.
tenant
?.
status
!==
'active'
))
...
...
@@ -179,6 +181,13 @@ export class JwtAuthGuard implements CanActivate {
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
.
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
};
...
...
apps/ai-service/src/modules/auth/auth.controller.ts
View file @
4c3930dc
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
{
IntegrationAccessService
}
from
'../integration/integration-access.service'
;
import
{
SessionService
}
from
'./session.service'
;
...
...
@@ -73,7 +75,10 @@ export class AuthController {
)
{
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
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
:
'/'
});
/**
* ⭐ 把这个来源记成"熟客" —— 全局限流闸对它豁免。
...
...
@@ -85,6 +90,7 @@ export class AuthController {
res
.
setHeader
(
'Cache-Control'
,
'no-store'
);
return
{
...
tokens
,
workspaces
,
displayName
:
r
.
displayName
,
tenantName
:
r
.
tenantName
,
// ⚠️ 临时密码登录也**照样发令牌** —— 否则他连改密码的接口都调不了。
...
...
@@ -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
);
}
/**
* ⭐⭐ 开户激活 —— 管理员开了号之后,本人凭一次性链接**自己**设手机号和密码。
*
...
...
apps/ai-service/src/modules/auth/session.service.ts
View file @
4c3930dc
...
...
@@ -48,6 +48,7 @@ export class SessionService {
source
:
'password'
|
'activation'
|
'development'
=
'password'
,
expectedVersion
?:
number
,
scope
:
'tenant'
|
'platform'
=
'tenant'
,
expiresAtLimit
?:
Date
,
):
Promise
<
SessionTokens
>
{
const
user
=
await
this
.
prisma
.
user
.
findUniqueOrThrow
({
where
:
{
id
:
userId
},
...
...
@@ -67,7 +68,8 @@ export class SessionService {
const
grant
=
await
this
.
prisma
.
userRoleGrant
.
findUnique
({
where
:
{
userId_scope
:
{
userId
,
scope
}
}
});
if
(
!
grant
||
(
scope
===
'tenant'
&&
grant
.
tenantId
!==
user
.
tenantId
))
throw
new
UnauthorizedException
(
'没有该工作区的访问权限'
);
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
nonce
=
randomUUID
();
const
refreshToken
=
this
.
jwt
.
signRefresh
(
...
...
@@ -98,6 +100,30 @@ export class SessionService {
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 的短期重试不被误判成重放。 */
async
refresh
(
token
:
string
,
requestId
:
string
):
Promise
<
SessionTokens
>
{
const
claims
=
this
.
jwt
.
verify
(
token
,
'refresh'
);
...
...
apps/ai-service/tests/platform-rbac.integration.spec.ts
View file @
4c3930dc
...
...
@@ -270,4 +270,56 @@ run('统一用户与平台 RBAC(现有本地数据库)', () => {
),
).
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
();
});
});
apps/ai-service/tests/unified-login.spec.ts
0 → 100644
View file @
4c3930dc
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
();
});
});
apps/ai-service/tests/web-auth-session.spec.ts
View file @
4c3930dc
...
...
@@ -155,5 +155,5 @@ it('平台账号连续刷新页面仍保留平台范围和空租户', async () =
expect
(
await
load
().
accessToken
()).
toBe
(
platform
.
accessToken
);
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'
}
});
expect
(
load
().
loginDestination
()).
toBe
(
'/
platform/
login'
);
expect
(
load
().
loginDestination
()).
toBe
(
'/login'
);
});
apps/ai-web/src/app/platform/login/page.tsx
deleted
100644 → 0
View file @
060c36b6
import
{
LoginForm
}
from
'@/components/login-form'
;
export
default
function
PlatformLoginPage
()
{
return
<
LoginForm
platform
/>;
}
apps/ai-web/src/app/platform/page.tsx
View file @
4c3930dc
...
...
@@ -2,6 +2,7 @@
import
{
useCallback
,
useEffect
,
useState
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
api
}
from
'@/lib/api'
;
import
{
WorkspaceSwitch
}
from
'@/components/workspace-switch'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
Input
}
from
'@/components/ui/input'
;
import
{
Checkbox
}
from
'@/components/ui/checkbox'
;
...
...
@@ -88,7 +89,7 @@ export default function PlatformPage() {
const
m
=
await
api
.
get
<
Me
>
('/platform/me');
if (!alive) return;
if (m.mustChangePassword)
{
router
.
replace
(
'/
platform/
login'
);
router
.
replace
(
'/login'
);
return
;
}
setMe(m);
...
...
@@ -154,10 +155,11 @@ export default function PlatformPage() {
</
div
>
<
div
className=
"flex items-center gap-3"
>
<
span
>
{
me
?.
displayName
}
</
span
>
{
me
&&
<
WorkspaceSwitch
/>
}
<
Button
variant=
"ghost"
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() {
<
div
role=
"alert"
className=
"rounded-xl bg-rose-50 p-4 text-rose-700"
>
{
error
}{
' '
}
{
!
me
?
(
<
Button
variant=
"outline"
onClick=
{
()
=>
router
.
replace
(
'/
platform/
login'
)
}
>
<
Button
variant=
"outline"
onClick=
{
()
=>
router
.
replace
(
'/login'
)
}
>
前往平台登录
</
Button
>
)
:
null
}
...
...
apps/ai-web/src/app/workspaces/page.tsx
0 → 100644
View file @
4c3930dc
'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
>
);
}
apps/ai-web/src/components/auth-session-boundary.tsx
View file @
4c3930dc
...
...
@@ -9,7 +9,7 @@ export function AuthSessionBoundary({ children }: { children: ReactNode }) {
const
pathname
=
usePathname
();
useEffect
(()
=>
{
const
changed
=
()
=>
{
if
(
!
currentSessionId
()
&&
pathname
!==
'/
platform/login'
&&
pathname
!==
'/
login'
&&
pathname
!==
'/activate'
&&
pathname
!==
'/embed'
)
if
(
!
currentSessionId
()
&&
pathname
!==
'/login'
&&
pathname
!==
'/activate'
&&
pathname
!==
'/embed'
)
router
.
replace
(
loginDestination
());
};
persistWindowSelection
();
...
...
apps/ai-web/src/components/login-form.tsx
View file @
4c3930dc
...
...
@@ -28,8 +28,9 @@ import { Input } from '@/components/ui/input';
*/
type
Mode
=
'login'
|
'change'
;
export
function
LoginForm
(
{
platform
=
false
}:
{
platform
?:
boolean
}
)
{
export
function
LoginForm
()
{
const
router
=
useRouter
();
const
[
scope
,
setScope
]
=
useState
<
'tenant'
|
'platform'
>
(
'tenant'
);
const
[
mode
,
setMode
]
=
useState
<
Mode
>
(
'login'
);
const
[
phone
,
setPhone
]
=
useState
(
''
);
const
[
password
,
setPassword
]
=
useState
(
''
);
...
...
@@ -71,14 +72,14 @@ export function LoginForm({ platform = false }: { platform?: boolean }) {
setBusy
(
true
);
setError
(
null
);
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'
,
{
phone
:
phoneNow
,
password
:
passwordNow
,
scope
:
platform
?
'platform'
:
'tenant'
,
},
);
setScope
(
r
.
scope
);
if
(
r
.
mustChangePassword
)
{
// ⚠️ 会话已经保存了,但后端守卫只放行 /auth/me 和 /auth/password。
// ⛔ 别在这儿 router.push('/'):会立刻 401 弹回来,白闪一下。
...
...
@@ -89,14 +90,14 @@ export function LoginForm({ platform = false }: { platform?: boolean }) {
}
// ⭐ 标一下「这次是新登录」—— 补资料的弹窗只在这种时候弹,⛔ 不是每次刷新
markJustLoggedIn
();
router
.
replace
(
platform
?
'/platform'
:
'/'
);
router
.
replace
(
r
.
workspaces
.
length
>
1
?
'/workspaces'
:
r
.
scope
===
'platform'
?
'/platform'
:
'/'
);
}
catch
(
e
)
{
setError
(
e
instanceof
ApiError
?
e
.
message
:
'登录失败,请稍后再试'
);
setPassword
(
''
);
}
finally
{
setBusy
(
false
);
}
},
[
busy
,
phone
,
password
,
router
,
platform
]);
},
[
busy
,
phone
,
password
,
router
]);
const
submitChange
=
useCallback
(
async
()
=>
{
if
(
busy
)
return
;
...
...
@@ -107,7 +108,7 @@ export function LoginForm({ platform = false }: { platform?: boolean }) {
setBusy
(
true
);
setError
(
null
);
try
{
await
api
.
post
(
platform
?
'/platform/password'
:
'/auth/password'
,
{
await
api
.
post
(
scope
===
'platform'
?
'/platform/password'
:
'/auth/password'
,
{
oldPassword
:
password
,
newPassword
:
next1
,
});
...
...
@@ -126,7 +127,7 @@ export function LoginForm({ platform = false }: { platform?: boolean }) {
}
finally
{
setBusy
(
false
);
}
},
[
busy
,
next1
,
next2
,
password
,
platform
]);
},
[
busy
,
next1
,
next2
,
password
,
scope
]);
return
(
<
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 }) {
<>
<
h1
className=
"text-[22px] font-semibold tracking-[-.4px]"
>
登录
</
h1
>
<
p
className=
"mt-[7px] text-[12.5px] leading-[1.7] text-slate-500 text-pretty"
>
{
platform
?
'使用平台管理账号登录。'
:
'用管理员给你的手机号和密码登录。登录后再绑定你的企业微信号。'
}
用管理员给你的手机号和密码登录。
</
p
>
<
form
...
...
apps/ai-web/src/components/sidebar.tsx
View file @
4c3930dc
...
...
@@ -5,6 +5,7 @@ import { useState } from 'react';
import
Link
from
'next/link'
;
import
{
usePathname
}
from
'next/navigation'
;
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
{
MyProfileDialog
}
from
'@/components/my-profile-dialog'
;
import
{
withInactive
}
from
'@/lib/labels'
;
...
...
@@ -268,6 +269,7 @@ export function Sidebar({
);
})
}
</
nav
>
{
me
&&
!
me
.
hostContext
&&
<
WorkspaceSwitch
confirmLeave=
{
confirmLeave
}
/>
}
{
/* 稿子里是一张圆角灰卡,不是一条分隔线 */
}
{
/* ⚠️ 未绑定时整张卡染色 —— 侧栏底部是账号动作的固定位置(⏻ 一直在这儿),
...
...
apps/ai-web/src/components/workspace-switch.tsx
0 → 100644
View file @
4c3930dc
'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
>
);
}
apps/ai-web/src/lib/api.ts
View file @
4c3930dc
...
...
@@ -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
}
`
);
const
body
=
(
await
res
.
json
())
as
Envelope
<
T
>
;
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
();
return
body
.
data
as
T
;
}
catch
(
e
)
{
...
...
apps/ai-web/src/lib/auth-session.ts
View file @
4c3930dc
...
...
@@ -78,8 +78,7 @@ export function persistWindowSelection(): void {
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
);
}
}
export
function
loginDestination
():
'/embed'
|
'/login'
|
'/platform/login'
{
if
(
typeof
window
!==
'undefined'
&&
window
.
location
?.
pathname
?.
startsWith
(
'/platform'
))
return
'/platform/login'
;
export
function
loginDestination
():
'/embed'
|
'/login'
{
// 宿主窗口的 sid 不是凭据;授权被别的窗口清掉后仍能辨认这个入口。
const
hostWindow
=
typeof
window
!==
'undefined'
&&
window
.
location
?.
href
&&
new
URL
(
window
.
location
.
href
).
searchParams
.
has
(
'sid'
);
...
...
docs/friday-ai-platform-management.md
View file @
4c3930dc
...
...
@@ -2,7 +2,7 @@
## 使用
页面:
`/platform/login`
→
`/platform`
。与客户工作台使用同一域名、同一服务
。
统一登录页:
`/login`
。仅客户身份进入
`/`
,仅平台身份进入
`/platform`
,同时拥有两种身份时进入
`/workspaces`
选择,并可在工作台切换。权限和数据范围按当前会话隔离,宿主内嵌会话不提供切换
。
平台管理首期提供:
...
...
@@ -76,7 +76,7 @@ pnpm --filter @pac/ai-service ai:platform revoke \
2.
备份 AI 数据库,构建与迁移版本一致的后端、前端产物。
3.
停止旧 AI 服务,运行
`prisma migrate deploy`
,再启动新版本。
4.
迁移包括
`20261009150000_hosting_seats`
与
`20261009190000_platform_rbac`
。
5.
CLI 开通首名平台管理员,访问
`/
platform/
login`
,改密后验证席位与审计页面。
5.
CLI 开通首名平台管理员,访问
`/login`
,改密后验证席位与审计页面。
6.
验证原客户登录、宿主内嵌、角色调整和通讯录功能。
不能迁移后只回滚旧代码:旧版角色更新会被投影保护触发器拒绝,旧版也不认识纯平台用户的空客户字段。失败时保持维护状态,修复后重启;如需数据库回滚,先备份故障现场,再使用部署前备份并核对期间写入。
...
...
docs/friday-ai-test-deployment.md
View file @
4c3930dc
...
...
@@ -197,6 +197,6 @@ docker compose -p pac-ai -f docker-compose.ai.yml exec ai-service \
--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
)
。
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment