feat(admin): Phase 1 注册邀请码逻辑 — 码必填 + kind 分流 + validate-invite + 注册页码优先重做
后端:Invitation.kind(join_team/create_team)+ team 可空 + migration;RegisterSerializer 邀请码必填, create_team 开新团队当 owner+发试用额度、join_team 幂等入伙不重复发额度,删「空码开团队」; validate-invite 免登录验码端点;团管发码恒 join_team;InvitationSerializer 增 kind/team_name。 前端:注册页码优先(验过才展开;create_team 填团队名 / join_team 只读团队名,不暴露团队列表)、 邀请链接 ?invite= 自动验码、api.validateInvite、验码行/状态提示样式(仅 token)。 测试:accounts 22/22 单测过(RegisterInviteCodeTests + ValidateInviteTests); 无头 e2e _admin-p1.mjs 全断言过 + 0 console error;tsc+build 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
443023a1a9
commit
3f8547564c
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -106,9 +106,16 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
}
|
||||
|
||||
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<AuthPayload>("/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<AuthPayload>("/api/auth/login/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
|
||||
@@ -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<InviteState>({ 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({
|
||||
<div className="auth-wrap">
|
||||
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
|
||||
{brand}
|
||||
<section className="auth-form" aria-label="注册团队">
|
||||
<div className="h-row"><h2>注册团队</h2><span className="sub">// /auth/register</span></div>
|
||||
<p className="lead">填写团队信息开通账户,默认成为团队超管。</p>
|
||||
<section className="auth-form" aria-label="注册">
|
||||
<div className="h-row"><h2>注册</h2><span className="sub">// /auth/register</span></div>
|
||||
<p className="lead">
|
||||
{inviteState.status === "valid"
|
||||
? inviteState.kind === "create_team"
|
||||
? "邀请码有效 · 开新团队,你将成为团队超管。"
|
||||
: `邀请码有效 · 加入「${inviteState.teamName || "团队"}」。`
|
||||
: "输入邀请码开通账户 —— 没有码请联系团队管理员或平台。"}
|
||||
</p>
|
||||
<form id="register-form" autoComplete="off" onSubmit={submitRegister} noValidate>
|
||||
<div className={`field${regErrors.team ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-team">团队名 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<Users className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<input
|
||||
id="reg-team"
|
||||
type="text"
|
||||
placeholder="例: 小李的店 / XX 文化传媒"
|
||||
value={regTeam}
|
||||
aria-invalid={Boolean(regErrors.team)}
|
||||
onChange={(event) => setRegValue("team", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`field${regErrors.username ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-username">用户名 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<UserRound className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<input
|
||||
id="reg-username"
|
||||
type="text"
|
||||
placeholder="登录用,建议字母 / 数字"
|
||||
value={regUsername}
|
||||
autoComplete="username"
|
||||
aria-invalid={Boolean(regErrors.username)}
|
||||
onChange={(event) => setRegValue("username", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field-row">
|
||||
<div className={`field${regErrors.password ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-pwd">密码 <span className="req">*</span></label>
|
||||
{/* 第一步:邀请码(必填)· 验过才展开后续表单 */}
|
||||
<div className={`field${inviteState.status === "invalid" ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-invite">邀请码 <span className="req">*</span></label>
|
||||
<div className="invite-verify-row">
|
||||
<div className="field-input-wrap">
|
||||
<LockKeyhole className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<Ticket className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<input
|
||||
id="reg-pwd"
|
||||
type={regShowPwd ? "text" : "password"}
|
||||
placeholder="至少 8 位"
|
||||
value={regPassword}
|
||||
autoComplete="new-password"
|
||||
aria-invalid={Boolean(regErrors.password)}
|
||||
onChange={(event) => setRegValue("password", event.target.value)}
|
||||
id="reg-invite"
|
||||
type="text"
|
||||
placeholder="例: TEAM-XXXX-XXXX"
|
||||
value={regInvite}
|
||||
autoComplete="off"
|
||||
aria-invalid={inviteState.status === "invalid"}
|
||||
onChange={(event) => onInviteChange(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void checkInvite(); } }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-pwd${regShowPwd ? " active" : ""}`}
|
||||
aria-label={regShowPwd ? "隐藏密码" : "显示密码"}
|
||||
aria-pressed={regShowPwd}
|
||||
title={regShowPwd ? "隐藏密码" : "显示密码"}
|
||||
onClick={() => setRegShowPwd((value) => !value)}
|
||||
>
|
||||
{regShowPwd ? <EyeOff size={18} strokeWidth={1.5} aria-hidden="true" /> : <Eye size={18} strokeWidth={1.5} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`field${regErrors.password2 ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-pwd2">确认密码 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<LockKeyhole className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<input
|
||||
id="reg-pwd2"
|
||||
type={regShowPwd2 ? "text" : "password"}
|
||||
placeholder="再输一次"
|
||||
value={regPassword2}
|
||||
autoComplete="new-password"
|
||||
aria-invalid={Boolean(regErrors.password2)}
|
||||
onChange={(event) => setRegValue("password2", event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-pwd${regShowPwd2 ? " active" : ""}`}
|
||||
aria-label={regShowPwd2 ? "隐藏密码" : "显示密码"}
|
||||
aria-pressed={regShowPwd2}
|
||||
title={regShowPwd2 ? "隐藏密码" : "显示密码"}
|
||||
onClick={() => setRegShowPwd2((value) => !value)}
|
||||
>
|
||||
{regShowPwd2 ? <EyeOff size={18} strokeWidth={1.5} aria-hidden="true" /> : <Eye size={18} strokeWidth={1.5} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn invite-verify-btn"
|
||||
disabled={inviteState.status === "checking" || !regInvite.trim()}
|
||||
onClick={() => void checkInvite()}
|
||||
>
|
||||
{inviteState.status === "checking" ? "验证中…" : inviteState.status === "valid" ? "重新验证" : "验证"}
|
||||
</button>
|
||||
</div>
|
||||
{inviteState.status === "invalid" && <p className="field-note err">{inviteState.detail}</p>}
|
||||
{inviteState.status === "valid" && (
|
||||
<p className="field-note ok">
|
||||
<ShieldCheck size={13} strokeWidth={1.8} aria-hidden="true" />
|
||||
{inviteState.kind === "create_team" ? "可开新团队" : `将加入「${inviteState.teamName || "团队"}」`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor="reg-invite">
|
||||
邀请码 <span className="hint">可选 · 团队邀请才需要</span>
|
||||
</label>
|
||||
<div className="field-input-wrap">
|
||||
<Ticket className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<input
|
||||
id="reg-invite"
|
||||
type="text"
|
||||
placeholder="例: TEAM-XXXX-XXXX"
|
||||
value={regInvite}
|
||||
onChange={(event) => { setRegInvite(event.target.value); setError(""); }}
|
||||
/>
|
||||
</div>
|
||||
<p className="field-note">
|
||||
{regInvite.trim() ? "填了码:将加入已有团队" : "没填码:将开新团队,你是超管"}
|
||||
</p>
|
||||
</div>
|
||||
{/* 第二步:验过码后展开 —— 不暴露任何团队列表 */}
|
||||
{inviteState.status === "valid" && (
|
||||
<>
|
||||
{inviteState.kind === "create_team" ? (
|
||||
<div className={`field${regErrors.team ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-team">团队名 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<Users className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<input
|
||||
id="reg-team"
|
||||
type="text"
|
||||
placeholder="例: 小李的店 / XX 文化传媒"
|
||||
value={regTeam}
|
||||
aria-invalid={Boolean(regErrors.team)}
|
||||
onChange={(event) => setRegValue("team", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="field">
|
||||
<label className="field-label">团队</label>
|
||||
<div className="field-input-wrap is-readonly">
|
||||
<Users className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<input type="text" value={inviteState.teamName || "已绑定团队"} readOnly disabled />
|
||||
</div>
|
||||
<p className="field-note">邀请码已绑定该团队,你将作为成员加入。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className={`agree${regErrors.agree ? " has-error" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={regAgree}
|
||||
onChange={(event) => { setRegAgree(event.target.checked); setRegErrors((c) => ({ ...c, agree: undefined })); setError(""); }}
|
||||
/>
|
||||
<span>
|
||||
我已阅读并同意{" "}
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("用户协议", "含数据处理 / 内容生成版权 / 计费规则三章 · 完整文本将在正式版接入"); }}>用户协议</a>{" "}
|
||||
与{" "}
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("隐私政策", "遵循《个人信息保护法》· 团队数据存中国境内 · 默认不用于模型训练"); }}>隐私政策</a>,
|
||||
知悉「失败不扣费 · 确认后扣」的扣费规则。
|
||||
</span>
|
||||
</label>
|
||||
<div className={`field${regErrors.username ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-username">用户名 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<UserRound className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<input
|
||||
id="reg-username"
|
||||
type="text"
|
||||
placeholder="登录用,建议字母 / 数字"
|
||||
value={regUsername}
|
||||
autoComplete="username"
|
||||
aria-invalid={Boolean(regErrors.username)}
|
||||
onChange={(event) => setRegValue("username", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loginProgress && !error && (
|
||||
<div className="login-progress" role="status" aria-live="polite">
|
||||
<span className="login-progress-spinner" aria-hidden="true"></span>
|
||||
<span>正在创建团队,进入 Airshelf…</span>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className={`field${regErrors.password ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-pwd">密码 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<LockKeyhole className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<input
|
||||
id="reg-pwd"
|
||||
type={regShowPwd ? "text" : "password"}
|
||||
placeholder="至少 8 位"
|
||||
value={regPassword}
|
||||
autoComplete="new-password"
|
||||
aria-invalid={Boolean(regErrors.password)}
|
||||
onChange={(event) => setRegValue("password", event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-pwd${regShowPwd ? " active" : ""}`}
|
||||
aria-label={regShowPwd ? "隐藏密码" : "显示密码"}
|
||||
aria-pressed={regShowPwd}
|
||||
title={regShowPwd ? "隐藏密码" : "显示密码"}
|
||||
onClick={() => setRegShowPwd((value) => !value)}
|
||||
>
|
||||
{regShowPwd ? <EyeOff size={18} strokeWidth={1.5} aria-hidden="true" /> : <Eye size={18} strokeWidth={1.5} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`field${regErrors.password2 ? " has-error" : ""}`}>
|
||||
<label className="field-label" htmlFor="reg-pwd2">确认密码 <span className="req">*</span></label>
|
||||
<div className="field-input-wrap">
|
||||
<LockKeyhole className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
<input
|
||||
id="reg-pwd2"
|
||||
type={regShowPwd2 ? "text" : "password"}
|
||||
placeholder="再输一次"
|
||||
value={regPassword2}
|
||||
autoComplete="new-password"
|
||||
aria-invalid={Boolean(regErrors.password2)}
|
||||
onChange={(event) => setRegValue("password2", event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`toggle-pwd${regShowPwd2 ? " active" : ""}`}
|
||||
aria-label={regShowPwd2 ? "隐藏密码" : "显示密码"}
|
||||
aria-pressed={regShowPwd2}
|
||||
title={regShowPwd2 ? "隐藏密码" : "显示密码"}
|
||||
onClick={() => setRegShowPwd2((value) => !value)}
|
||||
>
|
||||
{regShowPwd2 ? <EyeOff size={18} strokeWidth={1.5} aria-hidden="true" /> : <Eye size={18} strokeWidth={1.5} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className={`agree${regErrors.agree ? " has-error" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={regAgree}
|
||||
onChange={(event) => { setRegAgree(event.target.checked); setRegErrors((c) => ({ ...c, agree: undefined })); setError(""); }}
|
||||
/>
|
||||
<span>
|
||||
我已阅读并同意{" "}
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("用户协议", "含数据处理 / 内容生成版权 / 计费规则三章 · 完整文本将在正式版接入"); }}>用户协议</a>{" "}
|
||||
与{" "}
|
||||
<a href="#" onClick={(event) => { event.preventDefault(); showToast("隐私政策", "遵循《个人信息保护法》· 团队数据存中国境内 · 默认不用于模型训练"); }}>隐私政策</a>。
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{loginProgress && !error && (
|
||||
<div className="login-progress" role="status" aria-live="polite">
|
||||
<span className="login-progress-spinner" aria-hidden="true"></span>
|
||||
<span>{inviteState.kind === "create_team" ? "正在创建团队,进入 Airshelf…" : "正在加入团队,进入 Airshelf…"}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
|
||||
<button className="btn-cta" type="submit" disabled={busy}>
|
||||
{busy
|
||||
? "提交中"
|
||||
: inviteState.kind === "create_team"
|
||||
? <>创建团队 · 开始使用 <ArrowRight size={15} strokeWidth={2} aria-hidden="true" /></>
|
||||
: <>加入团队 · 开始使用 <ArrowRight size={15} strokeWidth={2} aria-hidden="true" /></>}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
|
||||
<button className="btn-cta" type="submit" disabled={busy}>
|
||||
{busy ? "创建团队中" : <>创建团队 · 开始使用 <ArrowRight size={15} strokeWidth={2} aria-hidden="true" /></>}
|
||||
</button>
|
||||
{inviteState.status !== "valid" && error && <div className="form-error" role="alert">{error}</div>}
|
||||
|
||||
<div className="switch-row">
|
||||
已有账号? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}>去登录 →</a>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user