diff --git a/core/backend/apps/accounts/migrations/0005_invitation_kind_alter_invitation_team.py b/core/backend/apps/accounts/migrations/0005_invitation_kind_alter_invitation_team.py new file mode 100644 index 0000000..ea739b6 --- /dev/null +++ b/core/backend/apps/accounts/migrations/0005_invitation_kind_alter_invitation_team.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.15 on 2026-06-19 12:54 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_user_is_platform_admin_adminauditlog'), + ] + + operations = [ + migrations.AddField( + model_name='invitation', + name='kind', + field=models.CharField(choices=[('join_team', 'Join Team'), ('create_team', 'Create Team')], default='join_team', max_length=16), + ), + migrations.AlterField( + model_name='invitation', + name='team', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='accounts.team'), + ), + ] diff --git a/core/backend/apps/accounts/models.py b/core/backend/apps/accounts/models.py index f58f6b0..682a6f3 100644 --- a/core/backend/apps/accounts/models.py +++ b/core/backend/apps/accounts/models.py @@ -118,7 +118,10 @@ def _default_invite_expiry(): class Invitation(TimeStampedModel): - """团队邀请码:超管/团管生成,新用户凭码注册即加入该团队(幂等加成员、不重复发额度)。""" + """邀请码 · 两类(kind): + - join_team(团管/超管发):绑定某团队,新用户凭码注册即加入该团队当成员(幂等、不重复发额度)。 + - create_team(平台超管发):不绑团队,新用户凭码注册 = 开新团队 + 当 owner(发试用额度)。 + 注册必须有码(取消「空码开团队」),前端先验码再展开对应表单。""" class Status(models.TextChoices): PENDING = "pending", "Pending" @@ -126,7 +129,13 @@ class Invitation(TimeStampedModel): REVOKED = "revoked", "Revoked" EXPIRED = "expired", "Expired" - team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="invitations") + class Kind(models.TextChoices): + JOIN_TEAM = "join_team", "Join Team" + CREATE_TEAM = "create_team", "Create Team" + + kind = models.CharField(max_length=16, choices=Kind.choices, default=Kind.JOIN_TEAM) + # join_team 必绑团队;create_team 开团队前无团队,故可空。 + team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="invitations", null=True, blank=True) code = models.CharField(max_length=32, unique=True, default=_gen_invite_code) role = models.CharField(max_length=24, choices=TeamMember.Role.choices, default=TeamMember.Role.MEMBER) email = models.EmailField(blank=True) diff --git a/core/backend/apps/accounts/serializers.py b/core/backend/apps/accounts/serializers.py index 3bf0a92..619c32b 100644 --- a/core/backend/apps/accounts/serializers.py +++ b/core/backend/apps/accounts/serializers.py @@ -62,8 +62,9 @@ class RegisterSerializer(serializers.Serializer): password = serializers.CharField(min_length=8, write_only=True) email = serializers.EmailField(required=False, allow_blank=True) team_name = serializers.CharField(max_length=128, required=False, allow_blank=True) - # 邀请码(可选):有效码 → 加入已有团队;空 → 开新团队当超管。认证定调:不用邮箱注册,username 为主标识。 - invite_code = serializers.CharField(max_length=32, required=False, allow_blank=True) + # 邀请码必填(本期定调):create_team 码 → 开新团队当 owner;join_team 码 → 加入该团队当成员。 + # 取消「空码 = 任何人开新团队」。认证定调:不用邮箱注册,username 为主标识。 + invite_code = serializers.CharField(max_length=32, required=True, allow_blank=False) def validate_username(self, value): if User.objects.filter(username=value).exists(): @@ -73,7 +74,7 @@ class RegisterSerializer(serializers.Serializer): def validate_invite_code(self, value): code = (value or "").strip().upper() if not code: - return "" + raise serializers.ValidationError("请填写邀请码") from django.utils import timezone from .models import Invitation @@ -98,38 +99,20 @@ class RegisterSerializer(serializers.Serializer): invite_code = (validated_data.get("invite_code") or "").strip().upper() with transaction.atomic(): + inv = Invitation.objects.select_for_update().filter(code=invite_code).first() + if inv is None or inv.status != Invitation.Status.PENDING or inv.expires_at <= timezone.now(): + raise serializers.ValidationError({"invite_code": "邀请码已失效"}) + user = User.objects.create_user( username=validated_data["username"], password=validated_data["password"], email=validated_data.get("email", ""), ) - if invite_code: - # 凭码加入已有团队:幂等加成员(避开 unique_together 撞 IntegrityError)、标邀请 used、 - # 整段跳过 CreditAccount + ledger(团队级 OneToOne 已存在,二次建必报错、额度也不重发)。 - inv = Invitation.objects.select_for_update().filter(code=invite_code).first() - if inv is None or inv.status != Invitation.Status.PENDING or inv.expires_at <= timezone.now(): - raise serializers.ValidationError({"invite_code": "邀请码已失效"}) - team = inv.team - role = inv.role - if role == TeamMember.Role.OWNER: - role = TeamMember.Role.ADMIN # 不允许 OWNER(Team.owner 唯一) - TeamMember.objects.get_or_create( - team=team, - user=user, - defaults={ - "role": role, - "status": TeamMember.Status.ACTIVE, - "monthly_credit_limit": inv.monthly_credit_limit, - }, - ) - inv.status = Invitation.Status.USED - inv.used_by = user - inv.used_at = timezone.now() - inv.save(update_fields=["status", "used_by", "used_at", "updated_at"]) - else: - # 无码:开新团队当 owner + 发试用额度(原逻辑) + + if inv.kind == Invitation.Kind.CREATE_TEAM: + # 开团队码:开新团队 + 当 owner + 发试用额度(原「无码开团队」逻辑,改为凭 create_team 码触发) team = Team.objects.create( - name=validated_data.get("team_name") or f"{user.username}'s Team", + name=(validated_data.get("team_name") or "").strip() or f"{user.username}'s Team", owner=user, ) TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER) @@ -146,18 +129,45 @@ class RegisterSerializer(serializers.Serializer): reason="新用户试用额度赠送", metadata={"kind": "trial_grant"}, ) + # 回填邀请所属团队,便于审计「这个开团队码开出了哪个团队」 + inv.team = team + else: + # join_team 码:幂等加入已有团队当成员(避开 unique_together 撞 IntegrityError)、 + # 整段跳过 CreditAccount + ledger(团队级 OneToOne 已存在,二次建必报错、额度也不重发)。 + team = inv.team + if team is None: + raise serializers.ValidationError({"invite_code": "邀请码未绑定团队"}) + role = inv.role + if role == TeamMember.Role.OWNER: + role = TeamMember.Role.ADMIN # 不允许 OWNER(Team.owner 唯一) + TeamMember.objects.get_or_create( + team=team, + user=user, + defaults={ + "role": role, + "status": TeamMember.Status.ACTIVE, + "monthly_credit_limit": inv.monthly_credit_limit, + }, + ) + + inv.status = Invitation.Status.USED + inv.used_by = user + inv.used_at = timezone.now() + inv.save(update_fields=["status", "used_by", "used_at", "team", "updated_at"]) return {"user": user, "team": team} class InvitationSerializer(serializers.ModelSerializer): used_by_username = serializers.CharField(source="used_by.username", read_only=True, default=None) + team_name = serializers.CharField(source="team.name", read_only=True, default=None) register_url = serializers.SerializerMethodField() class Meta: model = Invitation fields = [ - "id", "code", "role", "email", "monthly_credit_limit", "status", - "expires_at", "used_by", "used_by_username", "used_at", "created_at", "register_url", + "id", "code", "kind", "role", "email", "monthly_credit_limit", "status", + "team", "team_name", "expires_at", "used_by", "used_by_username", + "used_at", "created_at", "register_url", ] read_only_fields = fields diff --git a/core/backend/apps/accounts/tests.py b/core/backend/apps/accounts/tests.py index 133d244..b46a11b 100644 --- a/core/backend/apps/accounts/tests.py +++ b/core/backend/apps/accounts/tests.py @@ -4,10 +4,15 @@ from django.conf import settings from django.test import TestCase from rest_framework.test import APIClient -from apps.accounts.models import Team, TeamMember, User +from apps.accounts.models import Invitation, Team, TeamMember, User from apps.billing.models import CreditAccount, CreditLedger +def make_create_team_code(): + """铸一个 create_team 邀请码(平台超管发的「开团队码」)。本期注册必须有码。""" + return Invitation.objects.create(kind=Invitation.Kind.CREATE_TEAM).code + + class AuthApiTests(TestCase): def test_register_creates_user_team_member_credit_account_and_token(self): client = APIClient() @@ -18,6 +23,7 @@ class AuthApiTests(TestCase): "password": "strong-password", "email": "owner@example.com", "team_name": "Launch Team", + "invite_code": make_create_team_code(), }, format="json", ) @@ -35,7 +41,12 @@ class AuthApiTests(TestCase): client = APIClient() client.post( "/api/auth/register/", - {"username": "ledger-owner", "password": "strong-password", "team_name": "Ledger Team"}, + { + "username": "ledger-owner", + "password": "strong-password", + "team_name": "Ledger Team", + "invite_code": make_create_team_code(), + }, format="json", ) team = Team.objects.get(name="Ledger Team") @@ -57,10 +68,8 @@ class InvitationFlowTests(TestCase): ) def test_invite_join_same_team_no_duplicate_credits(self): - from apps.accounts.models import Invitation - owner_client = APIClient() - r = self._register(owner_client, "inv-owner", team_name="Owner Team") + r = self._register(owner_client, "inv-owner", team_name="Owner Team", invite_code=make_create_team_code()) self.assertEqual(r.status_code, 201) team = Team.objects.get(name="Owner Team") accounts_before = CreditAccount.objects.filter(team=team).count() @@ -104,7 +113,7 @@ class InvitationFlowTests(TestCase): def test_invite_generate_requires_manage_permission(self): owner_client = APIClient() - ro = self._register(owner_client, "perm-owner", team_name="Perm Team") + ro = self._register(owner_client, "perm-owner", team_name="Perm Team", invite_code=make_create_team_code()) owner_client.credentials(HTTP_AUTHORIZATION=f"Token {ro.data['token']}") gen = owner_client.post("/api/auth/team/invitations/", {"role": "member"}, format="json") code = gen.data["code"] @@ -193,9 +202,146 @@ class PlatformAdminTests(TestCase): client = APIClient() r = client.post( "/api/auth/register/", - {"username": "regular-owner", "password": "strong-password", "team_name": "Reg Team"}, + { + "username": "regular-owner", + "password": "strong-password", + "team_name": "Reg Team", + "invite_code": make_create_team_code(), + }, format="json", ) self.assertEqual(r.status_code, 201) self.assertFalse(r.data["user"]["is_platform_admin"]) + +class RegisterInviteCodeTests(TestCase): + """Phase 1:注册必须有码,kind 分流 create_team / join_team;取消「空码开团队」。""" + + def _register(self, username, **extra): + return APIClient().post( + "/api/auth/register/", + {"username": username, "password": "strong-password", **extra}, + format="json", + ) + + def test_register_requires_invite_code(self): + r = self._register("no-code-user", team_name="No Code Team") + self.assertEqual(r.status_code, 400) + self.assertIn("invite_code", r.data) + self.assertFalse(User.objects.filter(username="no-code-user").exists()) + self.assertFalse(Team.objects.filter(name="No Code Team").exists()) + + def test_register_blank_invite_code_rejected(self): + r = self._register("blank-code-user", team_name="Blank", invite_code="") + self.assertEqual(r.status_code, 400) + self.assertFalse(User.objects.filter(username="blank-code-user").exists()) + + def test_create_team_code_opens_new_team_with_trial(self): + code = make_create_team_code() + r = self._register("ct-owner", team_name="Created Team", invite_code=code) + self.assertEqual(r.status_code, 201) + user = User.objects.get(username="ct-owner") + team = Team.objects.get(name="Created Team") + self.assertEqual(team.owner, user) + self.assertTrue(TeamMember.objects.filter(team=team, user=user, role=TeamMember.Role.OWNER).exists()) + # 试用额度发放 + trial = Decimal(str(settings.DEFAULT_TRIAL_CREDITS)) + self.assertEqual(CreditAccount.objects.get(team=team).balance, trial) + # 邀请标 used + 回填团队 + inv = Invitation.objects.get(code=code) + self.assertEqual(inv.status, Invitation.Status.USED) + self.assertEqual(inv.used_by, user) + self.assertEqual(inv.team, team) + + def test_join_team_code_joins_existing_no_new_team(self): + # 先用 create_team 码开团队 + 团管发 join 码 + owner_client = APIClient() + ro = owner_client.post( + "/api/auth/register/", + {"username": "jt-owner", "password": "strong-password", "team_name": "Host Team", "invite_code": make_create_team_code()}, + format="json", + ) + owner_client.credentials(HTTP_AUTHORIZATION=f"Token {ro.data['token']}") + gen = owner_client.post("/api/auth/team/invitations/", {"role": "member"}, format="json") + self.assertEqual(gen.status_code, 201) + self.assertEqual(gen.data["kind"], Invitation.Kind.JOIN_TEAM) + code = gen.data["code"] + + host_team = Team.objects.get(name="Host Team") + teams_before = Team.objects.count() + r = self._register("jt-joiner", invite_code=code) + self.assertEqual(r.status_code, 201) + joiner = User.objects.get(username="jt-joiner") + self.assertTrue(TeamMember.objects.filter(team=host_team, user=joiner).exists()) + self.assertFalse(Team.objects.filter(owner=joiner).exists()) # 没开新团队 + self.assertEqual(Team.objects.count(), teams_before) + + def test_register_with_used_code_rejected(self): + code = make_create_team_code() + self._register("first-use", team_name="T1", invite_code=code) + r2 = self._register("second-use", team_name="T2", invite_code=code) + self.assertEqual(r2.status_code, 400) + self.assertFalse(User.objects.filter(username="second-use").exists()) + + def test_register_with_expired_code_rejected(self): + from datetime import timedelta + + from django.utils import timezone + + inv = Invitation.objects.create(kind=Invitation.Kind.CREATE_TEAM) + inv.expires_at = timezone.now() - timedelta(days=1) + inv.save(update_fields=["expires_at"]) + r = self._register("expired-user", team_name="Exp", invite_code=inv.code) + self.assertEqual(r.status_code, 400) + self.assertFalse(User.objects.filter(username="expired-user").exists()) + + +class ValidateInviteTests(TestCase): + """Phase 1:免登录验码端点 —— 注册页「填码 → 验过才展开」。""" + + def test_validate_create_team_code(self): + code = make_create_team_code() + r = APIClient().post("/api/auth/validate-invite/", {"code": code}, format="json") + self.assertEqual(r.status_code, 200) + self.assertTrue(r.data["valid"]) + self.assertEqual(r.data["kind"], Invitation.Kind.CREATE_TEAM) + self.assertIsNone(r.data["team_name"]) + + def test_validate_join_team_code_returns_team_name(self): + owner_client = APIClient() + ro = owner_client.post( + "/api/auth/register/", + {"username": "vi-owner", "password": "strong-password", "team_name": "Visible Team", "invite_code": make_create_team_code()}, + format="json", + ) + owner_client.credentials(HTTP_AUTHORIZATION=f"Token {ro.data['token']}") + gen = owner_client.post("/api/auth/team/invitations/", {"role": "member"}, format="json") + code = gen.data["code"] + + r = APIClient().post("/api/auth/validate-invite/", {"code": code}, format="json") + self.assertEqual(r.status_code, 200) + self.assertTrue(r.data["valid"]) + self.assertEqual(r.data["kind"], Invitation.Kind.JOIN_TEAM) + self.assertEqual(r.data["team_name"], "Visible Team") + + def test_validate_unknown_code_invalid(self): + r = APIClient().post("/api/auth/validate-invite/", {"code": "TEAM-ZZZZ-ZZZZ"}, format="json") + self.assertEqual(r.status_code, 200) + self.assertFalse(r.data["valid"]) + + def test_validate_blank_code_invalid(self): + r = APIClient().post("/api/auth/validate-invite/", {"code": ""}, format="json") + self.assertEqual(r.status_code, 200) + self.assertFalse(r.data["valid"]) + + def test_validate_used_code_invalid(self): + code = make_create_team_code() + APIClient().post( + "/api/auth/register/", + {"username": "vi-consumer", "password": "strong-password", "team_name": "Consumed", "invite_code": code}, + format="json", + ) + r = APIClient().post("/api/auth/validate-invite/", {"code": code}, format="json") + self.assertEqual(r.status_code, 200) + self.assertFalse(r.data["valid"]) + diff --git a/core/backend/apps/accounts/urls.py b/core/backend/apps/accounts/urls.py index a9acead..ea9fdea 100644 --- a/core/backend/apps/accounts/urls.py +++ b/core/backend/apps/accounts/urls.py @@ -16,11 +16,13 @@ from .views import ( team_member_password, team_members, update_avatar, + validate_invite, ) urlpatterns = [ path("register/", register, name="auth-register"), + path("validate-invite/", validate_invite, name="auth-validate-invite"), path("login/", login, name="auth-login"), path("logout/", logout, name="auth-logout"), path("me/", me, name="auth-me"), diff --git a/core/backend/apps/accounts/views.py b/core/backend/apps/accounts/views.py index 470b30f..57b899f 100644 --- a/core/backend/apps/accounts/views.py +++ b/core/backend/apps/accounts/views.py @@ -83,6 +83,34 @@ def register(request): return Response(auth_payload(data["user"], data["team"], token), status=status.HTTP_201_CREATED) +@api_view(["POST"]) +@permission_classes([]) +def validate_invite(request): + """免登录验码:注册页「填码 → 验过才展开表单」用。 + 返回 {valid, kind, team_name?, role?};无效返回 {valid:false, detail}(始终 200,不暴露是否存在以外的细节)。""" + from django.utils import timezone + + code = str(request.data.get("code") or request.data.get("invite_code") or "").strip().upper() + if not code: + return Response({"valid": False, "detail": "请填写邀请码"}) + inv = Invitation.objects.select_related("team").filter(code=code).first() + if inv is None: + return Response({"valid": False, "detail": "邀请码不存在"}) + if inv.status != Invitation.Status.PENDING: + return Response({"valid": False, "detail": "邀请码已被使用或已撤销"}) + if inv.expires_at <= timezone.now(): + return Response({"valid": False, "detail": "邀请码已过期"}) + return Response( + { + "valid": True, + "kind": inv.kind, + "role": inv.role, + # join_team 回团队名(只读展示);create_team 无团队 + "team_name": inv.team.name if inv.team_id else None, + } + ) + + @api_view(["POST"]) @permission_classes([]) def login(request): @@ -265,6 +293,7 @@ def team_invitations(request): role = TeamMember.Role.ADMIN # 邀请不允许 OWNER(Team.owner 唯一) invite = Invitation.objects.create( team=team, + kind=Invitation.Kind.JOIN_TEAM, # 团管发的码恒为「加入本团队」 role=role, email=str(request.data.get("email") or "").strip(), monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or 0, diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index cfeb595..e647749 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -106,9 +106,16 @@ async function request(path: string, options: RequestInit = {}): Promise { } export const api = { - register(payload: { username: string; password: string; email?: string; team_name?: string; invite_code?: string }) { + register(payload: { username: string; password: string; email?: string; team_name?: string; invite_code: string }) { return request("/api/auth/register/", { method: "POST", body: JSON.stringify(payload) }); }, + // 免登录验码:注册页「填码 → 验过才展开表单」。始终 200,以 valid 标志区分。 + validateInvite(code: string) { + return request<{ valid: boolean; kind?: "join_team" | "create_team"; role?: string; team_name?: string | null; detail?: string }>( + "/api/auth/validate-invite/", + { method: "POST", body: JSON.stringify({ code }) } + ); + }, login(payload: { username: string; password: string }) { return request("/api/auth/login/", { method: "POST", body: JSON.stringify(payload) }); }, diff --git a/core/frontend/src/routes/auth-screen.tsx b/core/frontend/src/routes/auth-screen.tsx index 19c393e..6977586 100644 --- a/core/frontend/src/routes/auth-screen.tsx +++ b/core/frontend/src/routes/auth-screen.tsx @@ -36,6 +36,13 @@ type RegisterErrors = { agree?: string; }; +// 注册码优先:先验码,验过才展开对应表单(create_team 开团队 / join_team 加入已有团队)。 +type InviteState = + | { status: "idle" } + | { status: "checking" } + | { status: "valid"; kind: "join_team" | "create_team"; teamName: string | null; role?: string } + | { status: "invalid"; detail: string }; + export function AuthScreen({ initialMode, onModeChange, @@ -61,6 +68,7 @@ export function AuthScreen({ const [regPassword, setRegPassword] = useState(""); const [regPassword2, setRegPassword2] = useState(""); const [regInvite, setRegInvite] = useState(""); + const [inviteState, setInviteState] = useState({ status: "idle" }); const [regAgree, setRegAgree] = useState(true); const [regShowPwd, setRegShowPwd] = useState(false); const [regShowPwd2, setRegShowPwd2] = useState(false); @@ -79,6 +87,43 @@ export function AuthScreen({ }, [initialMode]); useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []); + // 邀请链接 ?invite=CODE:进注册页自动回填 + 验码(团管/超管发的链接点开即识别团队) + useEffect(() => { + if (mode !== "register") return; + const code = new URLSearchParams(window.location.search).get("invite"); + if (code && !regInvite) { + setRegInvite(code); + void checkInvite(code); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode]); + + // 验码:验过才展开对应表单;未验/无效不暴露团队信息 + async function checkInvite(rawCode?: string) { + const code = (rawCode ?? regInvite).trim().toUpperCase(); + setError(""); + if (!code) { setInviteState({ status: "invalid", detail: "请填写邀请码" }); return; } + setInviteState({ status: "checking" }); + try { + const res = await api.validateInvite(code); + if (res.valid && res.kind) { + setInviteState({ status: "valid", kind: res.kind, teamName: res.team_name ?? null, role: res.role }); + if (res.kind === "join_team") setRegTeam(res.team_name || ""); + } else { + setInviteState({ status: "invalid", detail: res.detail || "邀请码无效或已过期" }); + } + } catch { + setInviteState({ status: "invalid", detail: "邀请码校验失败,请重试" }); + } + } + + // 改码即重置验证态:必须重新验证(避免拿旧验证结果提交) + function onInviteChange(value: string) { + setRegInvite(value); + setError(""); + if (inviteState.status !== "idle") setInviteState({ status: "idle" }); + } + function showToast(title: string, sub: string) { setToast({ title, sub }); if (toastTimer.current) clearTimeout(toastTimer.current); @@ -153,8 +198,13 @@ export function AuthScreen({ } function validateRegister() { + if (inviteState.status !== "valid") { + setError("请先验证邀请码"); + return false; + } const next: RegisterErrors = {}; - if (!regTeam.trim()) next.team = "请输入团队名"; + // 仅开团队码需要填团队名;加入码用只读团队名 + if (inviteState.kind === "create_team" && !regTeam.trim()) next.team = "请输入团队名"; if (!regUsername.trim()) next.username = "请输入用户名"; if (!regPassword) next.password = "请输入密码"; else if (regPassword.length < 8) next.password = "密码至少 8 位"; @@ -163,7 +213,7 @@ export function AuthScreen({ if (!regAgree) next.agree = "请阅读并同意用户协议"; setRegErrors(next); if (Object.keys(next).length > 0) { - setError("请补全必填字段后再创建团队"); + setError("请补全必填字段后再继续"); return false; } return true; @@ -172,17 +222,17 @@ export function AuthScreen({ async function submitRegister(event?: FormEvent) { event?.preventDefault(); if (busy) return; - if (!validateRegister()) return; + if (!validateRegister() || inviteState.status !== "valid") return; setBusy(true); setError(""); setLoginProgress("entering"); try { - const invite = regInvite.trim(); const payload = await api.register({ username: regUsername.trim(), password: regPassword, - team_name: regTeam.trim(), - ...(invite ? { invite_code: invite } : {}) + invite_code: regInvite.trim().toUpperCase(), + // 开团队码才带团队名;加入码服务端按邀请所属团队入伙 + ...(inviteState.kind === "create_team" ? { team_name: regTeam.trim() } : {}) }); // 注册即登录:记住新账号用户名,与登录一致地交给 onAuthed 完成身份+数据水合 persistRemember(regUsername.trim(), true); @@ -192,9 +242,9 @@ export function AuthScreen({ const raw = err instanceof Error ? err.message : ""; const friendly = /already exists|unique|taken/i.test(raw) ? "该用户名已被占用,换一个试试" - : /invite|invitation/i.test(raw) - ? "邀请码无效或已过期" - : raw || "创建团队失败,请稍后重试"; + : /invite|invitation|邀请码/i.test(raw) + ? "邀请码无效或已过期,请重新验证" + : raw || "提交失败,请稍后重试"; setError(friendly); } finally { setBusy(false); @@ -228,139 +278,182 @@ export function AuthScreen({
{brand} -
-

注册团队

// /auth/register
-

填写团队信息开通账户,默认成为团队超管。

+
+

注册

// /auth/register
+

+ {inviteState.status === "valid" + ? inviteState.kind === "create_team" + ? "邀请码有效 · 开新团队,你将成为团队超管。" + : `邀请码有效 · 加入「${inviteState.teamName || "团队"}」。` + : "输入邀请码开通账户 —— 没有码请联系团队管理员或平台。"} +

-
- -
-
-
- -
- -
-
-
- -
-
- + {/* 第一步:邀请码(必填)· 验过才展开后续表单 */} +
+ +
-
-
-
- -
-
+
+ {inviteState.status === "invalid" &&

{inviteState.detail}

} + {inviteState.status === "valid" && ( +

+

+ )}
-
- -
-
-

- {regInvite.trim() ? "填了码:将加入已有团队" : "没填码:将开新团队,你是超管"} -

-
+ {/* 第二步:验过码后展开 —— 不暴露任何团队列表 */} + {inviteState.status === "valid" && ( + <> + {inviteState.kind === "create_team" ? ( +
+ +
+
+
+ ) : ( +
+ +
+
+

邀请码已绑定该团队,你将作为成员加入。

+
+ )} - +
+ +
+
+
- {loginProgress && !error && ( -
- - 正在创建团队,进入 Airshelf… -
+
+
+ +
+
+
+
+ +
+
+
+
+ + + + {loginProgress && !error && ( +
+ + {inviteState.kind === "create_team" ? "正在创建团队,进入 Airshelf…" : "正在加入团队,进入 Airshelf…"} +
+ )} + {error &&
{error}
} + + + )} - {error &&
{error}
} - + {inviteState.status !== "valid" && error &&
{error}
}
已有账号? { event.preventDefault(); switchMode("login"); }}>去登录 → diff --git a/core/frontend/src/styles.css b/core/frontend/src/styles.css index e4c0b9f..376cdae 100644 --- a/core/frontend/src/styles.css +++ b/core/frontend/src/styles.css @@ -1217,6 +1217,32 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); } font-family: var(--font-mono); letter-spacing: .02em; } +/* 注册码优先:验码行(输入 + 验证按钮)+ 状态提示 + 只读团队名 */ +.auth-exact-page .invite-verify-row { + display: flex; + align-items: stretch; + gap: 8px; +} +.auth-exact-page .invite-verify-row .field-input-wrap { flex: 1; } +.auth-exact-page .invite-verify-btn { + flex-shrink: 0; + height: auto; + align-self: stretch; + padding: 0 18px; + white-space: nowrap; +} +.auth-exact-page .field-note.ok { + color: var(--accent-forest); + display: inline-flex; + align-items: center; + gap: 5px; +} +.auth-exact-page .field-note.err { color: var(--accent-crimson); } +.auth-exact-page .field-input-wrap.is-readonly input { + background: var(--black-alpha-4); + color: var(--black-alpha-56); + cursor: default; +} .auth-exact-page .agree { display: flex; align-items: flex-start; diff --git a/core/qa/visual-parity/_admin-p0.mjs b/core/qa/visual-parity/_admin-p0.mjs index 1510f83..ef990b0 100644 --- a/core/qa/visual-parity/_admin-p0.mjs +++ b/core/qa/visual-parity/_admin-p0.mjs @@ -5,7 +5,7 @@ import { chromium } from "playwright"; import fs from "node:fs"; import path from "node:path"; -const BASE = process.env.BASE || "http://localhost:5173"; +const BASE = process.env.BASE || "http://127.0.0.1:5188"; const API = process.env.API || "http://127.0.0.1:8010"; const OUT = path.resolve("output/admin"); fs.mkdirSync(OUT, { recursive: true }); diff --git a/core/qa/visual-parity/_admin-p1.mjs b/core/qa/visual-parity/_admin-p1.mjs new file mode 100644 index 0000000..b79f314 --- /dev/null +++ b/core/qa/visual-parity/_admin-p1.mjs @@ -0,0 +1,143 @@ +// Phase 1 e2e:注册码优先流程 —— 验码才展开、create_team 开团队 / join_team 加入(只读团队名)、错码拦截、URL 自动验码。 +// 沿用 _wave35-auth.mjs 模式:headless + 真点击 + locator 断言 + 截图 + 0 console error。 +// create_team 码经 wrapper 用 manage.py shell 铸好,传 CT1/CT2 env;join_team 码经 airshelf owner API 发。 +// 跑法:见 _admin-p1.run.sh(wrapper 负责铸码 + 传 env)。 +import { chromium } from "playwright"; +import fs from "node:fs"; +import path from "node:path"; + +const BASE = process.env.BASE || "http://127.0.0.1:5188"; +const API = process.env.API || "http://127.0.0.1:8010"; +const CT1 = process.env.CT1; +const CT2 = process.env.CT2; +const OUT = path.resolve("output/admin"); +fs.mkdirSync(OUT, { recursive: true }); +const stamp = Date.now(); + +async function apiLogin(u, p) { + const res = await fetch(`${API}/api/auth/login/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: u, password: p }), + }); + if (!res.ok) throw new Error(`login ${u} -> ${res.status}`); + return res.json(); +} + +const r = { createTeam: {}, joinTeam: {}, invalid: {}, urlAuto: {}, consoleErrors: [], pass: false }; + +if (!CT1 || !CT2) { + console.error("缺 CT1/CT2(create_team 码),请用 wrapper 跑"); + process.exit(2); +} + +// 用 airshelf owner 发一个 join_team 码(返回含 team_name) +const owner = await apiLogin("airshelf", "Restraint2026"); +const genRes = await fetch(`${API}/api/auth/team/invitations/`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Token ${owner.token}` }, + body: JSON.stringify({ role: "member" }), +}); +const gen = await genRes.json(); +const JT = gen.code; +r.joinTeam.expectedTeam = gen.team_name || owner.team?.name || ""; + +const browser = await chromium.launch({ headless: true }); +const hook = (p, tag) => { + p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push(`${tag}:${m.text()}`); }); + p.on("pageerror", (e) => r.consoleErrors.push(`${tag}:PAGEERR:${e.message}`)); +}; + +// ── A. create_team:验码 → 展开团队名输入 → 注册 → 进应用 ── +{ + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const p = await ctx.newPage(); hook(p, "createTeam"); + await p.goto(BASE + "/register", { waitUntil: "load" }); + await p.waitForTimeout(400); + r.createTeam.usernameHiddenBeforeVerify = (await p.locator("#reg-username").count()) === 0; + await p.fill("#reg-invite", CT1); + await p.click(".invite-verify-btn"); + await p.waitForSelector("#reg-team", { timeout: 8000 }).catch(() => {}); + await p.waitForTimeout(300); + r.createTeam.teamInputShown = (await p.locator("#reg-team").count()) === 1; + r.createTeam.okNote = (await p.locator(".field-note.ok").count()) >= 1; + await p.screenshot({ path: path.join(OUT, "p1-createteam-verified.png") }); + await p.fill("#reg-team", `E2E团队${stamp}`); + await p.fill("#reg-username", `p1-ct-${stamp}`); + await p.fill("#reg-pwd", "strong-pass-1"); + await p.fill("#reg-pwd2", "strong-pass-1"); + await p.click("button.btn-cta"); + try { await p.waitForFunction(() => !location.pathname.startsWith("/register"), { timeout: 15000 }); r.createTeam.registered = true; } + catch { r.createTeam.registered = false; } + await p.waitForTimeout(400); + r.createTeam.url = p.url(); + await ctx.close(); +} + +// ── B. join_team:验码 → 只读团队名(不让填团队名)→ 注册 → 进应用 ── +{ + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const p = await ctx.newPage(); hook(p, "joinTeam"); + await p.goto(BASE + "/register", { waitUntil: "load" }); + await p.waitForTimeout(400); + await p.fill("#reg-invite", JT); + await p.click(".invite-verify-btn"); + await p.waitForSelector("#reg-username", { timeout: 8000 }).catch(() => {}); + await p.waitForTimeout(300); + r.joinTeam.readonlyTeam = await p.locator(".field-input-wrap.is-readonly input").inputValue().catch(() => ""); + r.joinTeam.teamInputAbsent = (await p.locator("#reg-team").count()) === 0; + await p.screenshot({ path: path.join(OUT, "p1-jointeam-verified.png") }); + await p.fill("#reg-username", `p1-jt-${stamp}`); + await p.fill("#reg-pwd", "strong-pass-1"); + await p.fill("#reg-pwd2", "strong-pass-1"); + await p.click("button.btn-cta"); + try { await p.waitForFunction(() => !location.pathname.startsWith("/register"), { timeout: 15000 }); r.joinTeam.registered = true; } + catch { r.joinTeam.registered = false; } + await p.waitForTimeout(400); + await ctx.close(); +} + +// ── C. 错码:不展开表单 + 报错 ── +{ + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const p = await ctx.newPage(); hook(p, "invalid"); + await p.goto(BASE + "/register", { waitUntil: "load" }); + await p.waitForTimeout(400); + await p.fill("#reg-invite", "TEAM-ZZZZ-ZZZZ"); + await p.click(".invite-verify-btn"); + await p.waitForTimeout(900); + r.invalid.errNote = (await p.locator(".field-note.err").count()) >= 1; + r.invalid.usernameHidden = (await p.locator("#reg-username").count()) === 0; + await p.screenshot({ path: path.join(OUT, "p1-invalid-code.png") }); + await ctx.close(); +} + +// ── D. URL ?invite= 自动验码 ── +{ + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const p = await ctx.newPage(); hook(p, "urlAuto"); + await p.goto(BASE + "/register?invite=" + CT2, { waitUntil: "load" }); + await p.waitForSelector(".field-note.ok", { timeout: 8000 }).catch(() => {}); + await p.waitForTimeout(400); + r.urlAuto.autoVerified = (await p.locator(".field-note.ok").count()) >= 1; + r.urlAuto.codePrefilled = (await p.locator("#reg-invite").inputValue().catch(() => "")) === CT2; + await p.screenshot({ path: path.join(OUT, "p1-url-autovalidate.png") }); + await ctx.close(); +} + +await browser.close(); + +const checks = { + ctVerifyRevealsTeam: r.createTeam.usernameHiddenBeforeVerify && r.createTeam.teamInputShown && r.createTeam.okNote, + ctRegistered: r.createTeam.registered === true, + jtReadonlyTeamShown: !!r.joinTeam.readonlyTeam && r.joinTeam.teamInputAbsent, + jtRegistered: r.joinTeam.registered === true, + invalidBlocks: r.invalid.errNote === true && r.invalid.usernameHidden === true, + urlAutoValidate: r.urlAuto.autoVerified === true && r.urlAuto.codePrefilled === true, + zeroConsoleErrors: r.consoleErrors.length === 0, +}; +r.checks = checks; +r.pass = Object.values(checks).every(Boolean); +console.log(JSON.stringify(r, null, 2)); +fs.writeFileSync(path.join(OUT, "p1-summary.json"), JSON.stringify(r, null, 2)); +process.exit(r.pass ? 0 : 1); diff --git a/docs/todo/admin-后台-进度.md b/docs/todo/admin-后台-进度.md index 38ab2d2..f651e6f 100644 --- a/docs/todo/admin-后台-进度.md +++ b/docs/todo/admin-后台-进度.md @@ -38,3 +38,26 @@ - 视觉:概览截图核对 —— 冷灰底 + 单橙 accent + 8px 圆角 + 准星,符合 design.md 克制调性。 **测试账号**:平台超管 `admin/admin123`(测试 MySQL 已建);普通 demo `airshelf/Restraint2026`。 + +> **e2e 端口提示**:5173 被同机另一项目(AirDrama)抢占且不稳;AirShelf e2e 改用**专用端口 5188**(`npx vite --port 5188 --strictPort`)。e2e 脚本默认 BASE 已指向 5188。 + +--- + +## ✅ Phase 1 · 注册邀请码逻辑 — 完成 2026-06-19 + +**后端** +- `Invitation.kind`(join_team / create_team)+ `team` 可空 + migration `0005_invitation_kind_alter_invitation_team`(已 apply 测试 MySQL)。 +- `RegisterSerializer`:**invite_code 必填**;按 kind 分流 —— create_team 开新团队当 owner + 发试用额度(回填 invite.team),join_team 幂等加入该团队、不重复发额度;**删除「空码=开团队」**。 +- `validate-invite` 端点(免登录,POST `/api/auth/validate-invite/`):验码 → `{valid, kind, role, team_name?}`,无效始终 200+`valid:false`。 +- 团管发码恒为 `kind=join_team`;`InvitationSerializer` 增 `kind`/`team`/`team_name`。 + +**前端** +- 注册页重做(码优先):先填邀请码 → 点「验证」(或 Enter)→ 验过才展开;create_team 显示「团队名」输入,join_team 显示**只读团队名**(不暴露团队列表);CTA 文案随 kind 切换(创建团队 / 加入团队)。 +- 邀请链接 `?invite=CODE` 进页自动回填 + 验码;改码即重置验证态。 +- `api.validateInvite`;`field-note.ok/err` + `invite-verify-row` + 只读团队样式(仅 token)。 + +**测试底座(全过)** +- 后端单测:`apps.accounts` **22/22 OK**(新增 `RegisterInviteCodeTests` 6 项 + `ValidateInviteTests` 5 项:无码拒/空码拒/create_team 开团队+试用额度/join_team 入伙不开新团队/已用·过期拒/验码返回正确 kind+team_name)。 +- 无头 e2e:`_admin-p1.mjs`(端口 5188)—— **全断言过 + 0 console error**:create_team 验码→展开团队名→注册落工作台;join_team 验码→只读团队名「演示团队」→入伙;错码不展开+报错;URL 自动验码。截图 `output/admin/p1-*.png`。 +- 类型/构建:`tsc --noEmit` + `npm run build` 全绿。 +- 视觉:两态注册截图核对 —— 黑块等高、验证按钮对齐、绿色 ok 提示、只读团队名灰底,符合 restraint。