mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 20:50:58 +00:00
feat(oss): enforce invitation account and owner billing flows
This commit is contained in:
@@ -68,6 +68,9 @@ function SpaceOAuthCallbackContent() {
|
||||
'loading' | 'confirm' | 'success' | 'error'
|
||||
>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [terminalErrorCode, setTerminalErrorCode] = useState<
|
||||
'space_account_not_registered' | 'space_account_binding_required' | null
|
||||
>(null);
|
||||
const [isBindMode, setIsBindMode] = useState(false);
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -125,7 +128,15 @@ function SpaceOAuthCallbackContent() {
|
||||
}
|
||||
|
||||
setStatus('error');
|
||||
const errorObj = err as { msg?: string };
|
||||
const errorObj = err as { code?: string; msg?: string };
|
||||
if (
|
||||
errorObj.code === 'space_account_not_registered' ||
|
||||
errorObj.code === 'space_account_binding_required'
|
||||
) {
|
||||
setTerminalErrorCode(errorObj.code);
|
||||
setErrorMessage(t(`account.${errorObj.code}`));
|
||||
return;
|
||||
}
|
||||
const errMsg = (errorObj?.msg || '').toLowerCase();
|
||||
if (errMsg.includes('account email mismatch')) {
|
||||
setErrorMessage(t('account.spaceEmailMismatch'));
|
||||
@@ -168,7 +179,11 @@ function SpaceOAuthCallbackContent() {
|
||||
}
|
||||
|
||||
setStatus('error');
|
||||
const errorObj = err as { msg?: string };
|
||||
const errorObj = err as { code?: string; msg?: string };
|
||||
if (errorObj.code === 'space_account_email_mismatch') {
|
||||
setErrorMessage(t('account.spaceEmailMismatch'));
|
||||
return;
|
||||
}
|
||||
const errMsg = (errorObj?.msg || '').toLowerCase();
|
||||
if (errMsg.includes('account email mismatch')) {
|
||||
setErrorMessage(t('account.spaceEmailMismatch'));
|
||||
@@ -278,9 +293,11 @@ function SpaceOAuthCallbackContent() {
|
||||
? t('account.bindSpaceSuccess')
|
||||
: t('common.spaceLoginSuccess'))}
|
||||
{status === 'error' &&
|
||||
(isBindMode
|
||||
? t('account.bindSpaceFailed')
|
||||
: t('common.spaceLoginError'))}
|
||||
(terminalErrorCode
|
||||
? t(`account.${terminalErrorCode}Title`)
|
||||
: isBindMode
|
||||
? t('account.bindSpaceFailed')
|
||||
: t('common.spaceLoginError'))}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{status === 'loading' &&
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { PanelBody } from '../settings-dialog/panel-layout';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
|
||||
|
||||
interface ModelsPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
@@ -89,8 +90,8 @@ export default function ModelsPanel({
|
||||
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
|
||||
|
||||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||||
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
|
||||
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
|
||||
const [spaceBilling, setSpaceBilling] =
|
||||
useState<WorkspaceSpaceBilling | null>(null);
|
||||
|
||||
// Expanded providers and their models
|
||||
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
|
||||
@@ -144,7 +145,7 @@ export default function ModelsPanel({
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
loadUserInfo();
|
||||
loadWorkspaceBilling();
|
||||
loadProviders();
|
||||
loadRequesterSupportTypes();
|
||||
}
|
||||
@@ -167,16 +168,11 @@ export default function ModelsPanel({
|
||||
}
|
||||
}, [providersLoaded, providers]);
|
||||
|
||||
async function loadUserInfo() {
|
||||
async function loadWorkspaceBilling() {
|
||||
try {
|
||||
const userInfo = await httpClient.getUserInfo();
|
||||
setAccountType(userInfo.account_type);
|
||||
if (userInfo.account_type === 'space') {
|
||||
const creditsInfo = await httpClient.getSpaceCredits();
|
||||
setSpaceCredits(creditsInfo.credits);
|
||||
}
|
||||
setSpaceBilling(await httpClient.getWorkspaceSpaceBilling());
|
||||
} catch {
|
||||
setAccountType('local');
|
||||
setSpaceBilling(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,8 +542,9 @@ export default function ModelsPanel({
|
||||
isExpanded={expandedProviders.has(provider.uuid)}
|
||||
isLoading={loadingProviders.has(provider.uuid)}
|
||||
models={providerModels[provider.uuid]}
|
||||
accountType={accountType}
|
||||
spaceCredits={spaceCredits}
|
||||
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
|
||||
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
|
||||
spaceCredits={spaceBilling?.credits ?? null}
|
||||
addModelPopoverOpen={addModelPopoverOpen}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
deleteConfirmOpen={deleteConfirmOpen}
|
||||
|
||||
@@ -44,7 +44,8 @@ interface ProviderCardProps {
|
||||
isExpanded: boolean;
|
||||
isLoading: boolean;
|
||||
models?: ProviderModels;
|
||||
accountType: 'local' | 'space';
|
||||
isWorkspaceOwner: boolean;
|
||||
ownerSpaceBound: boolean;
|
||||
spaceCredits: number | null;
|
||||
// Popover states
|
||||
addModelPopoverOpen: string | null;
|
||||
@@ -108,7 +109,8 @@ export default function ProviderCard({
|
||||
isExpanded,
|
||||
isLoading,
|
||||
models,
|
||||
accountType,
|
||||
isWorkspaceOwner,
|
||||
ownerSpaceBound,
|
||||
spaceCredits,
|
||||
addModelPopoverOpen,
|
||||
editModelPopoverOpen,
|
||||
@@ -198,7 +200,7 @@ export default function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2 shrink-0">
|
||||
{canManage && isLangBotModels && accountType !== 'space' && (
|
||||
{isLangBotModels && isWorkspaceOwner && !ownerSpaceBound && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -208,32 +210,40 @@ export default function ProviderCard({
|
||||
}}
|
||||
>
|
||||
<LogIn className="h-4 w-4 mr-1" />
|
||||
{t('models.loginWithSpace')}
|
||||
{t('models.ownerMustBindSpace')}
|
||||
</Button>
|
||||
)}
|
||||
{isLangBotModels &&
|
||||
accountType === 'space' &&
|
||||
spaceCredits !== null && (
|
||||
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
|
||||
<span>
|
||||
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(
|
||||
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isLangBotModels && ownerSpaceBound && spaceCredits !== null && (
|
||||
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
|
||||
<span>
|
||||
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(
|
||||
`${systemInfo.cloud_service_url}/profile?tab=billing`,
|
||||
'_blank',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('models.usesOwnerSpaceBilling')}
|
||||
</span>
|
||||
)}
|
||||
{isLangBotModels && !isWorkspaceOwner && !ownerSpaceBound && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('models.ownerMustBindSpace')}
|
||||
</span>
|
||||
)}
|
||||
{canManage && !isLangBotModels && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -35,6 +35,12 @@ export interface CurrentWorkspace {
|
||||
plan_name?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceSpaceBilling {
|
||||
credits: number | null;
|
||||
owner_space_bound: boolean;
|
||||
is_workspace_owner: boolean;
|
||||
}
|
||||
|
||||
/** Account-scoped Workspace entry returned before a Workspace is selected. */
|
||||
export type WorkspaceBootstrapEntry = CurrentWorkspace;
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ import type {
|
||||
WorkspaceMembership,
|
||||
WorkspaceBootstrapResponse,
|
||||
WorkspaceRole,
|
||||
WorkspaceSpaceBilling,
|
||||
} from '@/app/infra/entities/workspace';
|
||||
|
||||
/**
|
||||
@@ -1146,10 +1147,8 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get('/api/v1/user/info', undefined, { skipWorkspace: true });
|
||||
}
|
||||
|
||||
public getSpaceCredits(): Promise<{ credits: number | null }> {
|
||||
return this.get('/api/v1/user/space-credits', undefined, {
|
||||
skipWorkspace: true,
|
||||
});
|
||||
public getWorkspaceSpaceBilling(): Promise<WorkspaceSpaceBilling> {
|
||||
return this.get('/api/v1/user/space-credits');
|
||||
}
|
||||
|
||||
public getAccountInfo(): Promise<{
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
} from '@/app/infra/entities/workspace';
|
||||
import {
|
||||
backendClient,
|
||||
beginAuthenticatedSession,
|
||||
bootstrapWorkspaceSession,
|
||||
clearPendingInvitationToken,
|
||||
clearUserInfo,
|
||||
@@ -168,10 +167,12 @@ export default function AcceptInvitationPage() {
|
||||
token,
|
||||
registration,
|
||||
);
|
||||
beginAuthenticatedSession(
|
||||
response.token,
|
||||
registration?.email ?? view?.invitation.normalized_email,
|
||||
);
|
||||
if (registration) {
|
||||
clearPendingInvitationToken();
|
||||
toast.success(t('workspace.invitationAccepted'));
|
||||
navigate('/login?invitation=1', { replace: true });
|
||||
return;
|
||||
}
|
||||
clearPendingInvitationToken();
|
||||
const workspaceResult = await bootstrapWorkspaceSession({
|
||||
preferredWorkspaceUuid: response.workspace_uuid,
|
||||
@@ -218,13 +219,14 @@ export default function AcceptInvitationPage() {
|
||||
});
|
||||
}
|
||||
|
||||
function switchAccount() {
|
||||
function logoutAndReturn() {
|
||||
if (token) setPendingInvitationToken(token);
|
||||
clearUserInfo();
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
}
|
||||
navigate('/login?invitation=1&auto=space', { replace: true });
|
||||
navigate('/login?invitation=1', { replace: true });
|
||||
}
|
||||
|
||||
function returnToLogin() {
|
||||
@@ -238,11 +240,6 @@ export default function AcceptInvitationPage() {
|
||||
|
||||
const hasLoginToken =
|
||||
typeof window !== 'undefined' && Boolean(localStorage.getItem('token'));
|
||||
const currentEmail =
|
||||
typeof window !== 'undefined' ? localStorage.getItem('userEmail') : null;
|
||||
const currentAccountMatches =
|
||||
currentEmail?.trim().toLocaleLowerCase() ===
|
||||
view?.invitation.normalized_email.toLocaleLowerCase();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4 dark:bg-neutral-900">
|
||||
@@ -307,24 +304,13 @@ export default function AcceptInvitationPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasLoginToken && currentAccountMatches ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={status === 'submitting'}
|
||||
onClick={() => void finishAcceptance()}
|
||||
>
|
||||
{status === 'submitting' && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{t('workspace.acceptAsCurrentAccount')}
|
||||
</Button>
|
||||
) : hasLoginToken ? (
|
||||
{hasLoginToken ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
{t('workspace.invitationEmailMismatch')}
|
||||
{t('workspace.authenticatedInvitationNotice')}
|
||||
</div>
|
||||
<Button className="w-full" onClick={switchAccount}>
|
||||
{t('workspace.switchAccount')}
|
||||
<Button className="w-full" onClick={logoutAndReturn}>
|
||||
{t('workspace.logoutAndReturn')}
|
||||
</Button>
|
||||
</div>
|
||||
) : passwordRegistrationEnabled ? (
|
||||
|
||||
@@ -279,6 +279,10 @@ const enUS = {
|
||||
credits: 'Credits',
|
||||
loginWithSpace: 'Login with LangBot Account',
|
||||
loginToUseModels: 'Login with Space to use cloud models',
|
||||
ownerMustBindSpace:
|
||||
'The Workspace owner must connect Space for LangBot Models.',
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's Space billing and credits.",
|
||||
noModels: 'No models configured',
|
||||
langbotModels: 'LangBot Models',
|
||||
spaceTrialTooltip:
|
||||
@@ -1283,7 +1287,13 @@ const enUS = {
|
||||
'Invalid bind request. Please try again from account settings.',
|
||||
setPasswordHint: 'Set a password to login with email and password',
|
||||
spaceEmailMismatch:
|
||||
'Space login email does not match the local account email',
|
||||
'The Space login email does not match the local account email.',
|
||||
space_account_not_registeredTitle: 'Account not registered',
|
||||
space_account_not_registered:
|
||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
||||
space_account_binding_requiredTitle: 'Space connection required',
|
||||
space_account_binding_required:
|
||||
'This local account must connect Space from Account settings before using Space login.',
|
||||
},
|
||||
workspace: {
|
||||
title: 'Workspace',
|
||||
@@ -1339,6 +1349,9 @@ const enUS = {
|
||||
existingAccountLoginRequired:
|
||||
'An account already exists for this email. Sign in to continue.',
|
||||
acceptAsCurrentAccount: 'Accept with current account',
|
||||
authenticatedInvitationNotice:
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
logoutAndReturn: 'Sign out and return to this invitation',
|
||||
switchAccount: 'Switch account',
|
||||
registerAndAccept: 'Create account and accept',
|
||||
alreadyHaveAccount: 'I already have an account',
|
||||
|
||||
@@ -284,6 +284,10 @@ const jaJP = {
|
||||
credits: 'クレジット',
|
||||
loginWithSpace: 'LangBot アカウントでログイン',
|
||||
loginToUseModels: 'Space でログインしてクラウドモデルを使用',
|
||||
ownerMustBindSpace:
|
||||
'LangBot モデルを使うにはワークスペース所有者が Space を連携する必要があります。',
|
||||
usesOwnerSpaceBilling:
|
||||
'ワークスペース所有者の Space 課金とクレジットを使用します。',
|
||||
noModels: 'モデルがありません',
|
||||
langbotModels: 'LangBot モデル',
|
||||
spaceTrialTooltip:
|
||||
@@ -1289,6 +1293,12 @@ const jaJP = {
|
||||
'パスワードを設定するとメールとパスワードでログインできます',
|
||||
spaceEmailMismatch:
|
||||
'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません',
|
||||
space_account_not_registeredTitle: 'アカウントが登録されていません',
|
||||
space_account_not_registered:
|
||||
'この Space メールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。',
|
||||
space_account_binding_requiredTitle: 'Space の連携が必要です',
|
||||
space_account_binding_required:
|
||||
'Space ログインを使用する前に、アカウント設定でこのローカルアカウントを Space に連携してください。',
|
||||
},
|
||||
workspace: {
|
||||
title: 'ワークスペース',
|
||||
@@ -1340,6 +1350,9 @@ const jaJP = {
|
||||
existingAccountLoginRequired:
|
||||
'このメールアドレスのアカウントは既に存在します。ログインしてください。',
|
||||
acceptAsCurrentAccount: '現在のアカウントで承認',
|
||||
authenticatedInvitationNotice:
|
||||
'一度ログアウトし、招待されたアカウントでログインしてください。招待は保持されます。',
|
||||
logoutAndReturn: 'ログアウトしてこの招待に戻る',
|
||||
switchAccount: 'アカウントを切り替える',
|
||||
registerAndAccept: 'アカウントを作成して承認',
|
||||
alreadyHaveAccount: 'アカウントを持っています',
|
||||
|
||||
@@ -267,6 +267,8 @@ const zhHans = {
|
||||
credits: '积分',
|
||||
loginWithSpace: '使用 LangBot 账号登录',
|
||||
loginToUseModels: '通过 Space 登录以使用云端模型',
|
||||
ownerMustBindSpace: '工作区所有者需要绑定 Space 才能使用 LangBot 模型。',
|
||||
usesOwnerSpaceBilling: '使用工作区所有者的 Space 计费与积分。',
|
||||
noModels: '暂无模型',
|
||||
langbotModels: 'LangBot 模型',
|
||||
spaceTrialTooltip:
|
||||
@@ -1219,6 +1221,12 @@ const zhHans = {
|
||||
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
|
||||
setPasswordHint: '设置密码后可使用邮箱密码登录',
|
||||
spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配',
|
||||
space_account_not_registeredTitle: '账户尚未注册',
|
||||
space_account_not_registered:
|
||||
'此 Space 邮箱尚无本地账户,请联系工作区所有者获取邀请。',
|
||||
space_account_binding_requiredTitle: '需要绑定 Space',
|
||||
space_account_binding_required:
|
||||
'此本地账户必须先在账户设置中绑定 Space,才能使用 Space 登录。',
|
||||
},
|
||||
workspace: {
|
||||
title: '工作区',
|
||||
@@ -1270,6 +1278,9 @@ const zhHans = {
|
||||
invitationEmailMismatch: '此邀请属于另一个邮箱地址。',
|
||||
existingAccountLoginRequired: '此邮箱已有账户,请登录后继续。',
|
||||
acceptAsCurrentAccount: '使用当前账户接受',
|
||||
authenticatedInvitationNotice:
|
||||
'请先退出,再使用受邀账户登录。邀请令牌会被保留。',
|
||||
logoutAndReturn: '退出并返回此邀请',
|
||||
switchAccount: '切换账号',
|
||||
registerAndAccept: '创建账户并接受',
|
||||
alreadyHaveAccount: '我已有账户',
|
||||
|
||||
Reference in New Issue
Block a user