mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-26 22:36:11 +00:00
feat(cloud): complete secure invitation experience
This commit is contained in:
@@ -4,6 +4,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
beginAuthenticatedSession,
|
||||
bootstrapWorkspaceSession,
|
||||
getPendingInvitationToken,
|
||||
} from '@/app/infra/http';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -91,6 +92,10 @@ function SpaceOAuthCallbackContent() {
|
||||
}
|
||||
|
||||
beginAuthenticatedSession(response.token, response.user);
|
||||
if (getPendingInvitationToken()) {
|
||||
navigate('/invitations/accept', { replace: true });
|
||||
return;
|
||||
}
|
||||
const workspaceResult = await bootstrapWorkspaceSession({
|
||||
preferredWorkspaceUuid: response.workspace_uuid,
|
||||
});
|
||||
|
||||
@@ -92,6 +92,7 @@ export default function AcceptInvitationPage() {
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => setInvitationHash(window.location.hash);
|
||||
@@ -108,6 +109,9 @@ export default function AcceptInvitationPage() {
|
||||
'error',
|
||||
);
|
||||
setToken(invitationToken);
|
||||
backendClient.getAccountInfo().then((info) => {
|
||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
||||
}).catch(() => setPasswordRegistrationEnabled(false));
|
||||
if (!invitationToken) {
|
||||
setErrorMessage(t('workspace.invitationMissing'));
|
||||
setStatus('error');
|
||||
@@ -210,6 +214,15 @@ export default function AcceptInvitationPage() {
|
||||
});
|
||||
}
|
||||
|
||||
function switchAccount() {
|
||||
clearUserInfo();
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
}
|
||||
navigate('/login?invitation=1&auto=space', { replace: true });
|
||||
}
|
||||
|
||||
function returnToLogin() {
|
||||
clearUserInfo();
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -221,6 +234,11 @@ 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">
|
||||
@@ -285,7 +303,7 @@ export default function AcceptInvitationPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasLoginToken ? (
|
||||
{hasLoginToken && currentAccountMatches ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={status === 'submitting'}
|
||||
@@ -296,7 +314,16 @@ export default function AcceptInvitationPage() {
|
||||
)}
|
||||
{t('workspace.acceptAsCurrentAccount')}
|
||||
</Button>
|
||||
) : (
|
||||
) : 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')}
|
||||
</div>
|
||||
<Button className="w-full" onClick={switchAccount}>
|
||||
{t('workspace.switchAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
) : passwordRegistrationEnabled ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
@@ -370,6 +397,10 @@ export default function AcceptInvitationPage() {
|
||||
{t('workspace.alreadyHaveAccount')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button className="w-full" onClick={() => navigate('/login?invitation=1&auto=space')}>
|
||||
{t('common.loginWithSpace')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [spaceLoading, setSpaceLoading] = useState(false);
|
||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] = useState(true);
|
||||
|
||||
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
||||
resolver: zodResolver(formSchema(t)),
|
||||
@@ -55,6 +56,10 @@ export default function Register() {
|
||||
|
||||
useEffect(() => {
|
||||
getIsInitialized();
|
||||
httpClient
|
||||
.getAccountInfo()
|
||||
.then((info) => setPasswordRegistrationEnabled(info.password_login_enabled !== false))
|
||||
.catch(() => setPasswordRegistrationEnabled(true));
|
||||
}, []);
|
||||
|
||||
function getIsInitialized() {
|
||||
@@ -159,20 +164,22 @@ export default function Register() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
|
||||
{t('common.or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{passwordRegistrationEnabled && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
|
||||
{t('common.or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Local Account Registration */}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Local Account Registration */}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
@@ -223,8 +230,10 @@ export default function Register() {
|
||||
>
|
||||
{t('register.registerWithPassword')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
{t('common.agreementNotice')}{' '}
|
||||
|
||||
@@ -80,7 +80,7 @@ const enUS = {
|
||||
loading: 'Loading...',
|
||||
fieldRequired: 'This field is required',
|
||||
or: 'or',
|
||||
loginWithSpace: 'Login with Space',
|
||||
loginWithSpace: 'Login with LangBot Account',
|
||||
spaceLoginRecommended:
|
||||
'Recommended: Use official stable model APIs and cloud services',
|
||||
loginLocal: 'Login with local account',
|
||||
@@ -277,7 +277,7 @@ const enUS = {
|
||||
searchProviders: 'Search providers...',
|
||||
langbotModelsDescription: 'Cloud models powered by LangBot Space',
|
||||
credits: 'Credits',
|
||||
loginWithSpace: 'Login with Space',
|
||||
loginWithSpace: 'Login with LangBot Account',
|
||||
loginToUseModels: 'Login with Space to use cloud models',
|
||||
noModels: 'No models configured',
|
||||
langbotModels: 'LangBot Models',
|
||||
@@ -1339,6 +1339,7 @@ const enUS = {
|
||||
existingAccountLoginRequired:
|
||||
'An account already exists for this email. Sign in to continue.',
|
||||
acceptAsCurrentAccount: 'Accept with current account',
|
||||
switchAccount: 'Switch account',
|
||||
registerAndAccept: 'Create account and accept',
|
||||
alreadyHaveAccount: 'I already have an account',
|
||||
confirmPassword: 'Confirm password',
|
||||
|
||||
@@ -83,7 +83,7 @@ const esES = {
|
||||
loading: 'Cargando...',
|
||||
fieldRequired: 'Este campo es obligatorio',
|
||||
or: 'o',
|
||||
loginWithSpace: 'Iniciar sesión con Space',
|
||||
loginWithSpace: 'Iniciar sesión con una cuenta de LangBot',
|
||||
spaceLoginRecommended:
|
||||
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
|
||||
loginLocal: 'Iniciar sesión con cuenta local',
|
||||
@@ -286,7 +286,7 @@ const esES = {
|
||||
searchProviders: 'Buscar proveedores...',
|
||||
langbotModelsDescription: 'Modelos en la nube impulsados por LangBot Space',
|
||||
credits: 'Créditos',
|
||||
loginWithSpace: 'Iniciar sesión con Space',
|
||||
loginWithSpace: 'Iniciar sesión con una cuenta de LangBot',
|
||||
loginToUseModels: 'Inicia sesión con Space para usar modelos en la nube',
|
||||
noModels: 'No hay modelos configurados',
|
||||
langbotModels: 'Modelos LangBot',
|
||||
|
||||
@@ -81,7 +81,7 @@ const jaJP = {
|
||||
loading: '読み込み中...',
|
||||
fieldRequired: 'この項目は必須です',
|
||||
or: 'または',
|
||||
loginWithSpace: 'Space でログイン',
|
||||
loginWithSpace: 'LangBot アカウントでログイン',
|
||||
spaceLoginRecommended:
|
||||
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
|
||||
loginLocal: 'ローカルアカウントでログイン',
|
||||
@@ -282,7 +282,7 @@ const jaJP = {
|
||||
searchProviders: 'プロバイダーを検索...',
|
||||
langbotModelsDescription: 'LangBot Space が提供するクラウドモデル',
|
||||
credits: 'クレジット',
|
||||
loginWithSpace: 'Space でログイン',
|
||||
loginWithSpace: 'LangBot アカウントでログイン',
|
||||
loginToUseModels: 'Space でログインしてクラウドモデルを使用',
|
||||
noModels: 'モデルがありません',
|
||||
langbotModels: 'LangBot モデル',
|
||||
@@ -1340,6 +1340,7 @@ const jaJP = {
|
||||
existingAccountLoginRequired:
|
||||
'このメールアドレスのアカウントは既に存在します。ログインしてください。',
|
||||
acceptAsCurrentAccount: '現在のアカウントで承認',
|
||||
switchAccount: 'アカウントを切り替える',
|
||||
registerAndAccept: 'アカウントを作成して承認',
|
||||
alreadyHaveAccount: 'アカウントを持っています',
|
||||
confirmPassword: 'パスワードを確認',
|
||||
|
||||
@@ -80,7 +80,7 @@ const ruRU = {
|
||||
loading: 'Загрузка...',
|
||||
fieldRequired: 'Это поле обязательно для заполнения',
|
||||
or: 'или',
|
||||
loginWithSpace: 'Войти через Space',
|
||||
loginWithSpace: 'Войти с аккаунтом LangBot',
|
||||
spaceLoginRecommended:
|
||||
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
|
||||
loginLocal: 'Войти с локальной учётной записью',
|
||||
@@ -284,7 +284,7 @@ const ruRU = {
|
||||
searchProviders: 'Поиск провайдеров...',
|
||||
langbotModelsDescription: 'Облачные модели на базе LangBot Space',
|
||||
credits: 'Кредиты',
|
||||
loginWithSpace: 'Войти через Space',
|
||||
loginWithSpace: 'Войти с аккаунтом LangBot',
|
||||
loginToUseModels: 'Войдите через Space, чтобы использовать облачные модели',
|
||||
noModels: 'Модели не настроены',
|
||||
langbotModels: 'Модели LangBot',
|
||||
|
||||
@@ -80,7 +80,7 @@ const thTH = {
|
||||
loading: 'กำลังโหลด...',
|
||||
fieldRequired: 'ช่องนี้จำเป็นต้องกรอก',
|
||||
or: 'หรือ',
|
||||
loginWithSpace: 'เข้าสู่ระบบด้วย Space',
|
||||
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
||||
spaceLoginRecommended:
|
||||
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
|
||||
loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น',
|
||||
@@ -273,7 +273,7 @@ const thTH = {
|
||||
searchProviders: 'ค้นหาผู้ให้บริการ...',
|
||||
langbotModelsDescription: 'โมเดลคลาวด์ขับเคลื่อนโดย LangBot Space',
|
||||
credits: 'เครดิต',
|
||||
loginWithSpace: 'เข้าสู่ระบบด้วย Space',
|
||||
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
||||
loginToUseModels: 'เข้าสู่ระบบด้วย Space เพื่อใช้โมเดลคลาวด์',
|
||||
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
|
||||
langbotModels: 'โมเดล LangBot',
|
||||
|
||||
@@ -81,7 +81,7 @@ const viVN = {
|
||||
loading: 'Đang tải...',
|
||||
fieldRequired: 'Trường này là bắt buộc',
|
||||
or: 'hoặc',
|
||||
loginWithSpace: 'Đăng nhập với Space',
|
||||
loginWithSpace: 'Đăng nhập bằng tài khoản LangBot',
|
||||
spaceLoginRecommended:
|
||||
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
|
||||
loginLocal: 'Đăng nhập với tài khoản cục bộ',
|
||||
@@ -281,7 +281,7 @@ const viVN = {
|
||||
searchProviders: 'Tìm kiếm nhà cung cấp...',
|
||||
langbotModelsDescription: 'Mô hình đám mây được cung cấp bởi LangBot Space',
|
||||
credits: 'Tín dụng',
|
||||
loginWithSpace: 'Đăng nhập với Space',
|
||||
loginWithSpace: 'Đăng nhập bằng tài khoản LangBot',
|
||||
loginToUseModels: 'Đăng nhập với Space để sử dụng mô hình đám mây',
|
||||
noModels: 'Chưa cấu hình mô hình nào',
|
||||
langbotModels: 'Mô hình LangBot',
|
||||
|
||||
@@ -79,7 +79,7 @@ const zhHans = {
|
||||
loading: '加载中...',
|
||||
fieldRequired: '此字段为必填项',
|
||||
or: '或',
|
||||
loginWithSpace: '通过 Space 登录',
|
||||
loginWithSpace: '使用 LangBot 账号登录',
|
||||
spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
|
||||
loginLocal: '使用本地账号登录',
|
||||
loginWithPassword: '通过密码登录',
|
||||
@@ -265,7 +265,7 @@ const zhHans = {
|
||||
searchProviders: '搜索供应商...',
|
||||
langbotModelsDescription: 'LangBot Space 提供的云端模型',
|
||||
credits: '积分',
|
||||
loginWithSpace: '通过 Space 登录',
|
||||
loginWithSpace: '使用 LangBot 账号登录',
|
||||
loginToUseModels: '通过 Space 登录以使用云端模型',
|
||||
noModels: '暂无模型',
|
||||
langbotModels: 'LangBot 模型',
|
||||
@@ -1270,6 +1270,7 @@ const zhHans = {
|
||||
invitationEmailMismatch: '此邀请属于另一个邮箱地址。',
|
||||
existingAccountLoginRequired: '此邮箱已有账户,请登录后继续。',
|
||||
acceptAsCurrentAccount: '使用当前账户接受',
|
||||
switchAccount: '切换账号',
|
||||
registerAndAccept: '创建账户并接受',
|
||||
alreadyHaveAccount: '我已有账户',
|
||||
confirmPassword: '确认密码',
|
||||
|
||||
@@ -79,7 +79,7 @@ const zhHant = {
|
||||
loading: '載入中...',
|
||||
fieldRequired: '此欄位為必填',
|
||||
or: '或',
|
||||
loginWithSpace: '透過 Space 登入',
|
||||
loginWithSpace: '使用 LangBot 帳號登入',
|
||||
spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
|
||||
loginLocal: '使用本地帳號登入',
|
||||
loginWithPassword: '透過密碼登入',
|
||||
@@ -263,7 +263,7 @@ const zhHant = {
|
||||
searchProviders: '搜尋供應商...',
|
||||
langbotModelsDescription: '由 LangBot Space 提供的雲端模型',
|
||||
credits: '積分',
|
||||
loginWithSpace: '使用 Space 登入',
|
||||
loginWithSpace: '使用 LangBot 帳號登入',
|
||||
loginToUseModels: '使用 Space 登入以使用雲端模型',
|
||||
noModels: '暫無模型',
|
||||
langbotModels: 'LangBot 模型',
|
||||
|
||||
Reference in New Issue
Block a user