"backend/internal/handler/vscode:/vscode.git/clone" did not exist on "4a3652ec09d54fa0973ac93d7b3b501a550098aa"
Commit 11c06803 authored by shaw's avatar shaw Committed by 陈曦
Browse files

feat(affiliate): 完善邀请返利系统

  - 修复返利不到账的根因:tryClaimAffiliateRebateAudit 中 PostgreSQL 参数类型推断冲突
  - 补全 OAuth 注册路径(LinuxDo/OIDC/WeChat/Pending Flow)的邀请码绑定
  - 前端 OAuth 注册页面传递 aff_code 参数
  - 新增返利冻结期机制:可配置冻结时间,到期后自动解冻(懒解冻)
  - 新增返利有效期:绑定后 N 天内有效,过期不再产生返利
  - 新增单人返利上限:超出上限部分精确截断
  - 增强返利流程 slog 结构化日志,便于排查问题
  - 已邀请用户列表增加返利明细列
parent 266179a3
...@@ -309,6 +309,9 @@ export interface SystemSettings { ...@@ -309,6 +309,9 @@ export interface SystemSettings {
// Default settings // Default settings
default_balance: number; default_balance: number;
affiliate_rebate_rate: number; affiliate_rebate_rate: number;
affiliate_rebate_freeze_hours: number;
affiliate_rebate_duration_days: number;
affiliate_rebate_per_invitee_cap: number;
default_concurrency: number; default_concurrency: number;
default_user_rpm_limit: number; default_user_rpm_limit: number;
default_subscriptions: DefaultSubscriptionSetting[]; default_subscriptions: DefaultSubscriptionSetting[];
...@@ -494,6 +497,9 @@ export interface UpdateSettingsRequest { ...@@ -494,6 +497,9 @@ export interface UpdateSettingsRequest {
totp_enabled?: boolean; // TOTP 双因素认证 totp_enabled?: boolean; // TOTP 双因素认证
default_balance?: number; default_balance?: number;
affiliate_rebate_rate?: number; affiliate_rebate_rate?: number;
affiliate_rebate_freeze_hours?: number;
affiliate_rebate_duration_days?: number;
affiliate_rebate_per_invitee_cap?: number;
default_concurrency?: number; default_concurrency?: number;
default_user_rpm_limit?: number; default_user_rpm_limit?: number;
default_subscriptions?: DefaultSubscriptionSetting[]; default_subscriptions?: DefaultSubscriptionSetting[];
......
...@@ -564,9 +564,10 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese ...@@ -564,9 +564,10 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
*/ */
export async function completeLinuxDoOAuthRegistration( export async function completeLinuxDoOAuthRegistration(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
return createPendingLinuxDoOAuthAccount(invitationCode, decision) return createPendingLinuxDoOAuthAccount(invitationCode, decision, affiliateCode)
} }
/** /**
...@@ -576,27 +577,32 @@ export async function completeLinuxDoOAuthRegistration( ...@@ -576,27 +577,32 @@ export async function completeLinuxDoOAuthRegistration(
*/ */
export async function completeOIDCOAuthRegistration( export async function completeOIDCOAuthRegistration(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
return createPendingOIDCOAuthAccount(invitationCode, decision) return createPendingOIDCOAuthAccount(invitationCode, decision, affiliateCode)
} }
export async function completeWeChatOAuthRegistration( export async function completeWeChatOAuthRegistration(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
return createPendingWeChatOAuthAccount(invitationCode, decision) return createPendingWeChatOAuthAccount(invitationCode, decision, affiliateCode)
} }
async function createPendingOAuthAccount( async function createPendingOAuthAccount(
provider: 'linuxdo' | 'oidc' | 'wechat', provider: 'linuxdo' | 'oidc' | 'wechat',
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
const normalizedAffiliateCode = affiliateCode?.trim()
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>( const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
`/auth/oauth/${provider}/complete-registration`, `/auth/oauth/${provider}/complete-registration`,
{ {
invitation_code: invitationCode, invitation_code: invitationCode,
...(normalizedAffiliateCode ? { aff_code: normalizedAffiliateCode } : {}),
...serializeOAuthAdoptionDecision(decision) ...serializeOAuthAdoptionDecision(decision)
} }
) )
...@@ -605,23 +611,26 @@ async function createPendingOAuthAccount( ...@@ -605,23 +611,26 @@ async function createPendingOAuthAccount(
export async function createPendingLinuxDoOAuthAccount( export async function createPendingLinuxDoOAuthAccount(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('linuxdo', invitationCode, decision) return createPendingOAuthAccount('linuxdo', invitationCode, decision, affiliateCode)
} }
export async function createPendingOIDCOAuthAccount( export async function createPendingOIDCOAuthAccount(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('oidc', invitationCode, decision) return createPendingOAuthAccount('oidc', invitationCode, decision, affiliateCode)
} }
export async function createPendingWeChatOAuthAccount( export async function createPendingWeChatOAuthAccount(
invitationCode: string, invitationCode: string,
decision?: OAuthAdoptionDecision decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> { ): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('wechat', invitationCode, decision) return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode)
} }
export async function completePendingOAuthBindLogin( export async function completePendingOAuthBindLogin(
......
...@@ -42,9 +42,11 @@ ...@@ -42,9 +42,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
withDefaults(defineProps<{ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
affCode?: string
showDivider?: boolean showDivider?: boolean
}>(), { }>(), {
showDivider: true showDivider: true
...@@ -55,6 +57,7 @@ const { t } = useI18n() ...@@ -55,6 +57,7 @@ const { t } = useI18n()
function startLogin(): void { function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard' const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}` const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}`
......
...@@ -23,9 +23,11 @@ ...@@ -23,9 +23,11 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
affCode?: string
providerName?: string providerName?: string
showDivider?: boolean showDivider?: boolean
}>(), { }>(), {
...@@ -45,6 +47,7 @@ const providerInitial = computed(() => normalizedProviderName.value.charAt(0).to ...@@ -45,6 +47,7 @@ const providerInitial = computed(() => normalizedProviderName.value.charAt(0).to
function startLogin(): void { function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard' const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}` const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}`
......
...@@ -33,9 +33,11 @@ import { useRoute } from 'vue-router' ...@@ -33,9 +33,11 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { resolveWeChatOAuthStart } from '@/api/auth' import { resolveWeChatOAuthStart } from '@/api/auth'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
affCode?: string
showDivider?: boolean showDivider?: boolean
}>(), { }>(), {
showDivider: true, showDivider: true,
...@@ -84,6 +86,7 @@ function startLogin(): void { ...@@ -84,6 +86,7 @@ function startLogin(): void {
return return
} }
const redirectTo = (route.query.redirect as string) || '/dashboard' const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1' const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '') const normalized = apiBase.replace(/\/$/, '')
const mode = resolvedStart.value.mode const mode = resolvedStart.value.mode
......
...@@ -996,6 +996,8 @@ export default { ...@@ -996,6 +996,8 @@ export default {
rebateRateHint: 'What you earn each time an invitee recharges', rebateRateHint: 'What you earn each time an invitee recharges',
invitedUsers: 'Invited Users', invitedUsers: 'Invited Users',
availableQuota: 'Available Rebate Quota', availableQuota: 'Available Rebate Quota',
frozenQuota: 'Frozen',
frozenQuotaHint: 'Recently earned rebates pending release',
totalQuota: 'Historical Rebate Quota' totalQuota: 'Historical Rebate Quota'
}, },
transfer: { transfer: {
...@@ -1012,6 +1014,7 @@ export default { ...@@ -1012,6 +1014,7 @@ export default {
columns: { columns: {
email: 'Email', email: 'Email',
username: 'Username', username: 'Username',
rebate: 'Rebate',
joinedAt: 'Joined At' joinedAt: 'Joined At'
} }
}, },
...@@ -1019,7 +1022,8 @@ export default { ...@@ -1019,7 +1022,8 @@ export default {
title: 'How It Works', title: 'How It Works',
line1: 'Share your affiliate code or invite link with new users.', line1: 'Share your affiliate code or invite link with new users.',
line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.', line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.',
line3: 'Transfer rebate quota to balance at any time.' line3: 'Transfer rebate quota to balance at any time.',
line4: 'Newly earned rebates may have a waiting period before they can be transferred.'
} }
}, },
...@@ -4795,6 +4799,12 @@ export default { ...@@ -4795,6 +4799,12 @@ export default {
enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.', enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.',
rebateRate: 'Global Rebate Rate', rebateRate: 'Global Rebate Rate',
rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).', rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).',
freezeHours: 'Rebate Freeze Period (hours)',
freezeHoursDesc: 'New rebates will be frozen for this period before becoming available for withdrawal. 0 = no freeze.',
durationDays: 'Rebate Duration (days)',
durationDaysDesc: 'Rebate relationship expires after this many days since invitee registration. 0 = permanent.',
perInviteeCap: 'Per-Invitee Rebate Cap',
perInviteeCapDesc: 'Maximum total rebate from a single invitee. 0 = no limit.',
customUsers: { customUsers: {
title: 'Per-User Overrides', title: 'Per-User Overrides',
description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.', description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.',
......
...@@ -1000,6 +1000,8 @@ export default { ...@@ -1000,6 +1000,8 @@ export default {
rebateRateHint: '被邀请用户每次充值后你可获得的返利比例', rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
invitedUsers: '邀请人数', invitedUsers: '邀请人数',
availableQuota: '可转返利额度', availableQuota: '可转返利额度',
frozenQuota: '冻结中',
frozenQuotaHint: '新产生的返利正在冻结期中',
totalQuota: '历史返利额度' totalQuota: '历史返利额度'
}, },
transfer: { transfer: {
...@@ -1016,6 +1018,7 @@ export default { ...@@ -1016,6 +1018,7 @@ export default {
columns: { columns: {
email: '邮箱', email: '邮箱',
username: '用户名', username: '用户名',
rebate: '返利明细',
joinedAt: '注册时间' joinedAt: '注册时间'
} }
}, },
...@@ -1023,7 +1026,8 @@ export default { ...@@ -1023,7 +1026,8 @@ export default {
title: '使用说明', title: '使用说明',
line1: '将邀请码或邀请链接分享给新用户。', line1: '将邀请码或邀请链接分享给新用户。',
line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。', line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
line3: '返利额度可随时转入账户余额。' line3: '返利额度可随时转入账户余额。',
line4: '新产生的返利需要经过冻结期后才能提现。'
} }
}, },
...@@ -4958,6 +4962,12 @@ export default { ...@@ -4958,6 +4962,12 @@ export default {
enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。', enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。',
rebateRate: '全局返利比例', rebateRate: '全局返利比例',
rebateRateHint: '充值后返给邀请人的默认比例(0-100%,例如填写 10 表示返利 10%)。', rebateRateHint: '充值后返给邀请人的默认比例(0-100%,例如填写 10 表示返利 10%)。',
freezeHours: '返利冻结期(小时)',
freezeHoursDesc: '新产生的返利将在冻结期内无法提现。0 = 不冻结。',
durationDays: '返利有效期(天)',
durationDaysDesc: '被邀请用户注册后多少天内的充值产生返利。0 = 永久有效。',
perInviteeCap: '单人返利上限',
perInviteeCapDesc: '每个被邀请用户最多产生的返利总额。0 = 无上限。',
customUsers: { customUsers: {
title: '专属用户配置', title: '专属用户配置',
description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。', description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',
......
...@@ -130,6 +130,7 @@ export interface AffiliateInvitee { ...@@ -130,6 +130,7 @@ export interface AffiliateInvitee {
email: string email: string
username: string username: string
created_at?: string created_at?: string
total_rebate: number
} }
export interface UserAffiliateDetail { export interface UserAffiliateDetail {
...@@ -138,6 +139,7 @@ export interface UserAffiliateDetail { ...@@ -138,6 +139,7 @@ export interface UserAffiliateDetail {
inviter_id?: number | null inviter_id?: number | null
aff_count: number aff_count: number
aff_quota: number aff_quota: number
aff_frozen_quota: number
aff_history_quota: number aff_history_quota: number
/** 当前用户作为邀请人时实际生效的返利比例(专属覆盖全局)。0-100。 */ /** 当前用户作为邀请人时实际生效的返利比例(专属覆盖全局)。0-100。 */
effective_rebate_rate_percent: number effective_rebate_rate_percent: number
......
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearAffiliateReferralCode,
clearOAuthAffiliateCode,
loadAffiliateReferralCode,
loadOAuthAffiliateCode,
resolveAffiliateReferralCode,
storeAffiliateReferralCode,
storeOAuthAffiliateCode
} from '@/utils/oauthAffiliate'
describe('oauthAffiliate', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.useRealTimers()
})
it('persists affiliate referral code across pages', () => {
expect(resolveAffiliateReferralCode(' 5579J7CFG9PF ')).toBe('5579J7CFG9PF')
expect(loadAffiliateReferralCode()).toBe('5579J7CFG9PF')
expect(resolveAffiliateReferralCode()).toBe('5579J7CFG9PF')
})
it('expires stale affiliate referral code', () => {
const now = Date.UTC(2026, 0, 1)
storeAffiliateReferralCode('AFF123', now)
expect(loadAffiliateReferralCode(now + 30 * 24 * 60 * 60 * 1000 - 1)).toBe('AFF123')
expect(loadAffiliateReferralCode(now + 30 * 24 * 60 * 60 * 1000 + 1)).toBe('')
expect(localStorage.getItem('affiliate_referral_code')).toBeNull()
})
it('keeps oauth transient code separate from persistent referral code', () => {
storeAffiliateReferralCode('PERSISTED')
storeOAuthAffiliateCode('OAUTH')
expect(loadAffiliateReferralCode()).toBe('PERSISTED')
expect(loadOAuthAffiliateCode()).toBe('OAUTH')
clearOAuthAffiliateCode()
expect(loadOAuthAffiliateCode()).toBe('')
expect(loadAffiliateReferralCode()).toBe('PERSISTED')
clearAffiliateReferralCode()
expect(loadAffiliateReferralCode()).toBe('')
})
})
const OAUTH_AFFILIATE_CODE_KEY = 'oauth_aff_code'
const AFFILIATE_REFERRAL_CODE_KEY = 'affiliate_referral_code'
const AFFILIATE_REFERRAL_TTL_MS = 30 * 24 * 60 * 60 * 1000
interface StoredAffiliateReferralCode {
code: string
expiresAt: number
}
export function normalizeOAuthAffiliateCode(value?: unknown): string {
const raw = Array.isArray(value) ? value[0] : value
return typeof raw === 'string' ? raw.trim() : ''
}
export function pickOAuthAffiliateCode(...values: unknown[]): string {
for (const value of values) {
const code = normalizeOAuthAffiliateCode(value)
if (code) {
return code
}
}
return ''
}
export function storeAffiliateReferralCode(value?: unknown, now = Date.now()): void {
if (typeof window === 'undefined') {
return
}
const code = normalizeOAuthAffiliateCode(value)
if (!code) {
return
}
try {
const payload: StoredAffiliateReferralCode = {
code,
expiresAt: now + AFFILIATE_REFERRAL_TTL_MS
}
window.localStorage.setItem(AFFILIATE_REFERRAL_CODE_KEY, JSON.stringify(payload))
} catch {
// 忽略浏览器存储异常。
}
}
export function loadAffiliateReferralCode(now = Date.now()): string {
if (typeof window === 'undefined') {
return ''
}
try {
const raw = window.localStorage.getItem(AFFILIATE_REFERRAL_CODE_KEY)
if (!raw) {
return ''
}
const parsed = JSON.parse(raw) as Partial<StoredAffiliateReferralCode>
const code = normalizeOAuthAffiliateCode(parsed.code)
const expiresAt = Number(parsed.expiresAt) || 0
if (!code || expiresAt <= now) {
clearAffiliateReferralCode()
return ''
}
return code
} catch {
clearAffiliateReferralCode()
return ''
}
}
export function clearAffiliateReferralCode(): void {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.removeItem(AFFILIATE_REFERRAL_CODE_KEY)
} catch {
// 忽略浏览器存储异常。
}
}
export function resolveAffiliateReferralCode(...values: unknown[]): string {
const code = pickOAuthAffiliateCode(...values)
if (code) {
storeAffiliateReferralCode(code)
return code
}
return loadAffiliateReferralCode()
}
export function storeOAuthAffiliateCode(value?: unknown): void {
if (typeof window === 'undefined') {
return
}
const code = normalizeOAuthAffiliateCode(value)
try {
if (code) {
window.sessionStorage.setItem(OAUTH_AFFILIATE_CODE_KEY, code)
} else {
window.sessionStorage.removeItem(OAUTH_AFFILIATE_CODE_KEY)
}
} catch {
// 忽略浏览器存储异常。
}
}
export function loadOAuthAffiliateCode(): string {
if (typeof window === 'undefined') {
return ''
}
try {
return normalizeOAuthAffiliateCode(window.sessionStorage.getItem(OAUTH_AFFILIATE_CODE_KEY))
} catch {
return ''
}
}
export function clearOAuthAffiliateCode(): void {
if (typeof window === 'undefined') {
return
}
try {
window.sessionStorage.removeItem(OAUTH_AFFILIATE_CODE_KEY)
} catch {
// 忽略浏览器存储异常。
}
}
export function clearAllAffiliateReferralCodes(): void {
clearOAuthAffiliateCode()
clearAffiliateReferralCode()
}
export function oauthAffiliatePayload(value?: unknown): { aff_code?: string } {
const code = normalizeOAuthAffiliateCode(value)
return code ? { aff_code: code } : {}
}
...@@ -3898,6 +3898,56 @@ ...@@ -3898,6 +3898,56 @@
</p> </p>
</div> </div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.freezeHours') }}
</label>
<input
v-model.number="form.affiliate_rebate_freeze_hours"
type="number"
step="1"
min="0"
max="720"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.freezeHoursDesc') }}
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.durationDays') }}
</label>
<input
v-model.number="form.affiliate_rebate_duration_days"
type="number"
step="1"
min="0"
max="3650"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.durationDaysDesc') }}
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.perInviteeCap') }}
</label>
<input
v-model.number="form.affiliate_rebate_per_invitee_cap"
type="number"
step="0.01"
min="0"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.perInviteeCapDesc') }}
</p>
</div>
<!-- 专属用户管理 --> <!-- 专属用户管理 -->
<div class="border-t border-gray-100 pt-6 dark:border-dark-700"> <div class="border-t border-gray-100 pt-6 dark:border-dark-700">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
...@@ -5333,6 +5383,9 @@ const form = reactive<SettingsForm>({ ...@@ -5333,6 +5383,9 @@ const form = reactive<SettingsForm>({
totp_encryption_key_configured: false, totp_encryption_key_configured: false,
default_balance: 0, default_balance: 0,
affiliate_rebate_rate: 20, affiliate_rebate_rate: 20,
affiliate_rebate_freeze_hours: 0,
affiliate_rebate_duration_days: 0,
affiliate_rebate_per_invitee_cap: 0,
default_concurrency: 1, default_concurrency: 1,
default_subscriptions: [], default_subscriptions: [],
force_email_on_third_party_signup: false, force_email_on_third_party_signup: false,
...@@ -6261,6 +6314,9 @@ async function saveSettings() { ...@@ -6261,6 +6314,9 @@ async function saveSettings() {
100, 100,
Math.max(0, Number(form.affiliate_rebate_rate) || 0), Math.max(0, Number(form.affiliate_rebate_rate) || 0),
), ),
affiliate_rebate_freeze_hours: Math.max(0, Math.min(720, Number(form.affiliate_rebate_freeze_hours) || 0)),
affiliate_rebate_duration_days: Math.max(0, Math.min(3650, Math.floor(Number(form.affiliate_rebate_duration_days) || 0))),
affiliate_rebate_per_invitee_cap: Math.max(0, Number(form.affiliate_rebate_per_invitee_cap) || 0),
default_concurrency: form.default_concurrency, default_concurrency: form.default_concurrency,
default_subscriptions: normalizedDefaultSubscriptions, default_subscriptions: normalizedDefaultSubscriptions,
force_email_on_third_party_signup: form.force_email_on_third_party_signup, force_email_on_third_party_signup: form.force_email_on_third_party_signup,
......
...@@ -167,6 +167,11 @@ import { ...@@ -167,6 +167,11 @@ import {
isRegistrationEmailSuffixAllowed, isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy' } from '@/utils/registrationEmailPolicy'
import {
clearAllAffiliateReferralCodes,
loadAffiliateReferralCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const { t, locale } = useI18n() const { t, locale } = useI18n()
...@@ -261,7 +266,7 @@ onMounted(async () => { ...@@ -261,7 +266,7 @@ onMounted(async () => {
initialTurnstileToken.value = registerData.turnstile_token || '' initialTurnstileToken.value = registerData.turnstile_token || ''
promoCode.value = registerData.promo_code || '' promoCode.value = registerData.promo_code || ''
invitationCode.value = registerData.invitation_code || '' invitationCode.value = registerData.invitation_code || ''
affCode.value = registerData.aff_code || '' affCode.value = registerData.aff_code || loadAffiliateReferralCode()
pendingAuthToken.value = registerData.pending_auth_token || activePendingSession?.token || '' pendingAuthToken.value = registerData.pending_auth_token || activePendingSession?.token || ''
pendingAuthTokenField.value = registerData.pending_auth_token_field || activePendingSession?.token_field || 'pending_auth_token' pendingAuthTokenField.value = registerData.pending_auth_token_field || activePendingSession?.token_field || 'pending_auth_token'
pendingProvider.value = registerData.pending_provider || activePendingSession?.provider || '' pendingProvider.value = registerData.pending_provider || activePendingSession?.provider || ''
...@@ -501,6 +506,7 @@ async function handleVerify(): Promise<void> { ...@@ -501,6 +506,7 @@ async function handleVerify(): Promise<void> {
password: password.value, password: password.value,
verify_code: verifyCode.value.trim(), verify_code: verifyCode.value.trim(),
invitation_code: invitationCode.value || undefined, invitation_code: invitationCode.value || undefined,
...oauthAffiliatePayload(affCode.value || loadAffiliateReferralCode()),
adopt_display_name: pendingAdoptionDecision.value?.adoptDisplayName, adopt_display_name: pendingAdoptionDecision.value?.adoptDisplayName,
adopt_avatar: pendingAdoptionDecision.value?.adoptAvatar adopt_avatar: pendingAdoptionDecision.value?.adoptAvatar
} }
...@@ -533,6 +539,7 @@ async function handleVerify(): Promise<void> { ...@@ -533,6 +539,7 @@ async function handleVerify(): Promise<void> {
// Clear session data // Clear session data
sessionStorage.removeItem('register_data') sessionStorage.removeItem('register_data')
clearAllAffiliateReferralCodes()
// Show success toast // Show success toast
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value })) appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))
......
...@@ -255,6 +255,11 @@ import { ...@@ -255,6 +255,11 @@ import {
type OAuthTokenResponse, type OAuthTokenResponse,
type PendingOAuthExchangeResponse type PendingOAuthExchangeResponse
} from '@/api/auth' } from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
...@@ -568,6 +573,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi ...@@ -568,6 +573,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') { if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile') const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession() clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage) appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect) await router.replace(bindRedirect)
return return
...@@ -579,6 +585,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi ...@@ -579,6 +585,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion) persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
} }
...@@ -627,18 +634,20 @@ async function handleSubmitInvitation() { ...@@ -627,18 +634,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true isSubmitting.value = true
try { try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: LinuxDoPendingActionResponse = legacyPendingOAuthToken.value const completion: LinuxDoPendingActionResponse = legacyPendingOAuthToken.value
? ( ? (
await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/linuxdo/complete-registration', { await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/linuxdo/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value, pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(), invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision()) ...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
}) })
).data ).data
: await completeLinuxDoOAuthRegistration( : affCode
invitationCode.value.trim(), ? await completeLinuxDoOAuthRegistration(invitationCode.value.trim(), decision, affCode)
currentAdoptionDecision() : await completeLinuxDoOAuthRegistration(invitationCode.value.trim(), decision)
)
await finalizePendingAccountResponse(completion) await finalizePendingAccountResponse(completion)
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } } const err = e as { message?: string; response?: { data?: { message?: string } } }
...@@ -673,6 +682,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) { ...@@ -673,6 +682,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password, password: payload.password,
verify_code: payload.verifyCode || undefined, verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined, invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision()) ...serializeAdoptionDecision(currentAdoptionDecision())
}) })
await finalizePendingAccountResponse(data) await finalizePendingAccountResponse(data)
...@@ -720,6 +730,7 @@ async function handleSubmitTotpChallenge() { ...@@ -720,6 +730,7 @@ async function handleSubmitTotpChallenge() {
totp_code: code totp_code: code
}) })
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value) await router.replace(redirectTo.value)
} catch (e: unknown) { } catch (e: unknown) {
...@@ -743,6 +754,7 @@ onMounted(async () => { ...@@ -743,6 +754,7 @@ onMounted(async () => {
if (legacyLogin) { if (legacyLogin) {
persistOAuthTokenContext(legacyLogin) persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token) await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
return return
......
...@@ -186,6 +186,7 @@ import TurnstileWidget from '@/components/TurnstileWidget.vue' ...@@ -186,6 +186,7 @@ import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores' import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth' import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
import type { TotpLoginResponse } from '@/types' import type { TotpLoginResponse } from '@/types'
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
const { t } = useI18n() const { t } = useI18n()
...@@ -355,6 +356,7 @@ async function handleLogin(): Promise<void> { ...@@ -355,6 +356,7 @@ async function handleLogin(): Promise<void> {
} }
// Show success toast // Show success toast
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route // Redirect to dashboard or intended route
...@@ -397,6 +399,7 @@ async function handle2FAVerify(code: string): Promise<void> { ...@@ -397,6 +399,7 @@ async function handle2FAVerify(code: string): Promise<void> {
// Close modal and show success // Close modal and show success
show2FAModal.value = false show2FAModal.value = false
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route // Redirect to dashboard or intended route
......
...@@ -264,6 +264,11 @@ import { ...@@ -264,6 +264,11 @@ import {
type OAuthTokenResponse, type OAuthTokenResponse,
type PendingOAuthExchangeResponse type PendingOAuthExchangeResponse
} from '@/api/auth' } from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
...@@ -590,6 +595,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi ...@@ -590,6 +595,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') { if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile') const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession() clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage) appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect) await router.replace(bindRedirect)
return return
...@@ -601,6 +607,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi ...@@ -601,6 +607,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion) persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
} }
...@@ -649,18 +656,20 @@ async function handleSubmitInvitation() { ...@@ -649,18 +656,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true isSubmitting.value = true
try { try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: PendingOidcCompletion = legacyPendingOAuthToken.value const completion: PendingOidcCompletion = legacyPendingOAuthToken.value
? ( ? (
await apiClient.post<PendingOidcCompletion>('/auth/oauth/oidc/complete-registration', { await apiClient.post<PendingOidcCompletion>('/auth/oauth/oidc/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value, pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(), invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision()) ...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
}) })
).data ).data
: await completeOIDCOAuthRegistration( : affCode
invitationCode.value.trim(), ? await completeOIDCOAuthRegistration(invitationCode.value.trim(), decision, affCode)
currentAdoptionDecision() : await completeOIDCOAuthRegistration(invitationCode.value.trim(), decision)
)
await finalizePendingAccountResponse(completion) await finalizePendingAccountResponse(completion)
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } } const err = e as { message?: string; response?: { data?: { message?: string } } }
...@@ -695,6 +704,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) { ...@@ -695,6 +704,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password, password: payload.password,
verify_code: payload.verifyCode || undefined, verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined, invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision()) ...serializeAdoptionDecision(currentAdoptionDecision())
}) })
await finalizePendingAccountResponse(data) await finalizePendingAccountResponse(data)
...@@ -742,6 +752,7 @@ async function handleSubmitTotpChallenge() { ...@@ -742,6 +752,7 @@ async function handleSubmitTotpChallenge() {
totp_code: code totp_code: code
}) })
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value) await router.replace(redirectTo.value)
} catch (e: unknown) { } catch (e: unknown) {
...@@ -767,6 +778,7 @@ onMounted(async () => { ...@@ -767,6 +778,7 @@ onMounted(async () => {
if (legacyLogin) { if (legacyLogin) {
persistOAuthTokenContext(legacyLogin) persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token) await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
return return
......
...@@ -15,17 +15,20 @@ ...@@ -15,17 +15,20 @@
<LinuxDoOAuthSection <LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled" v-if="linuxdoOAuthEnabled"
:disabled="isLoading" :disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false" :show-divider="false"
/> />
<WechatOAuthSection <WechatOAuthSection
v-if="wechatOAuthEnabled" v-if="wechatOAuthEnabled"
:disabled="isLoading" :disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false" :show-divider="false"
/> />
<OidcOAuthSection <OidcOAuthSection
v-if="oidcOAuthEnabled" v-if="oidcOAuthEnabled"
:disabled="isLoading" :disabled="isLoading"
:provider-name="oidcOAuthProviderName" :provider-name="oidcOAuthProviderName"
:aff-code="formData.aff_code"
:show-divider="false" :show-divider="false"
/> />
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
...@@ -293,6 +296,11 @@ import { ...@@ -293,6 +296,11 @@ import {
isRegistrationEmailSuffixAllowed, isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy' } from '@/utils/registrationEmailPolicy'
import {
clearAffiliateReferralCode,
loadAffiliateReferralCode,
resolveAffiliateReferralCode
} from '@/utils/oauthAffiliate'
const { t, locale } = useI18n() const { t, locale } = useI18n()
...@@ -378,9 +386,19 @@ watch(validationToastMessage, (value, previousValue) => { ...@@ -378,9 +386,19 @@ watch(validationToastMessage, (value, previousValue) => {
} }
}) })
function syncAffiliateReferralCode(): string {
const code = resolveAffiliateReferralCode(route.query.aff, route.query.aff_code)
if (code) {
formData.aff_code = code
}
return code
}
// ==================== Lifecycle ==================== // ==================== Lifecycle ====================
onMounted(async () => { onMounted(async () => {
syncAffiliateReferralCode()
try { try {
const settings = await getPublicSettings() const settings = await getPublicSettings()
registrationEnabled.value = settings.registration_enabled registrationEnabled.value = settings.registration_enabled
...@@ -407,10 +425,7 @@ onMounted(async () => { ...@@ -407,10 +425,7 @@ onMounted(async () => {
await validatePromoCodeDebounced(promoParam) await validatePromoCodeDebounced(promoParam)
} }
} }
const affParam = (route.query.aff as string) || (route.query.aff_code as string) syncAffiliateReferralCode()
if (affParam) {
formData.aff_code = affParam.trim()
}
} catch (error) { } catch (error) {
console.error('Failed to load public settings:', error) console.error('Failed to load public settings:', error)
} finally { } finally {
...@@ -418,6 +433,13 @@ onMounted(async () => { ...@@ -418,6 +433,13 @@ onMounted(async () => {
} }
}) })
watch(
() => [route.query.aff, route.query.aff_code],
() => {
syncAffiliateReferralCode()
}
)
onUnmounted(() => { onUnmounted(() => {
if (promoValidateTimeout) { if (promoValidateTimeout) {
clearTimeout(promoValidateTimeout) clearTimeout(promoValidateTimeout)
...@@ -702,6 +724,11 @@ async function handleRegister(): Promise<void> { ...@@ -702,6 +724,11 @@ async function handleRegister(): Promise<void> {
isLoading.value = true isLoading.value = true
try { try {
const affCode = formData.aff_code.trim() || loadAffiliateReferralCode()
if (affCode) {
formData.aff_code = affCode
}
// If email verification is enabled, redirect to verification page // If email verification is enabled, redirect to verification page
if (emailVerifyEnabled.value) { if (emailVerifyEnabled.value) {
// Store registration data in sessionStorage // Store registration data in sessionStorage
...@@ -713,7 +740,7 @@ async function handleRegister(): Promise<void> { ...@@ -713,7 +740,7 @@ async function handleRegister(): Promise<void> {
turnstile_token: turnstileToken.value, turnstile_token: turnstileToken.value,
promo_code: formData.promo_code || undefined, promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined, invitation_code: formData.invitation_code || undefined,
...(formData.aff_code ? { aff_code: formData.aff_code } : {}) ...(affCode ? { aff_code: affCode } : {})
}) })
) )
...@@ -729,8 +756,9 @@ async function handleRegister(): Promise<void> { ...@@ -729,8 +756,9 @@ async function handleRegister(): Promise<void> {
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined, turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
promo_code: formData.promo_code || undefined, promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined, invitation_code: formData.invitation_code || undefined,
...(formData.aff_code ? { aff_code: formData.aff_code } : {}) ...(affCode ? { aff_code: affCode } : {})
}) })
clearAffiliateReferralCode()
// Show success toast // Show success toast
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value })) appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))
......
...@@ -340,6 +340,11 @@ import { ...@@ -340,6 +340,11 @@ import {
type OAuthTokenResponse, type OAuthTokenResponse,
type PendingOAuthExchangeResponse type PendingOAuthExchangeResponse
} from '@/api/auth' } from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
...@@ -802,6 +807,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi ...@@ -802,6 +807,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') { if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile') const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession() clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage) appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect) await router.replace(bindRedirect)
return return
...@@ -813,6 +819,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi ...@@ -813,6 +819,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion) persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
} }
...@@ -861,18 +868,20 @@ async function handleSubmitInvitation() { ...@@ -861,18 +868,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true isSubmitting.value = true
try { try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: PendingWeChatCompletion = legacyPendingOAuthToken.value const completion: PendingWeChatCompletion = legacyPendingOAuthToken.value
? ( ? (
await apiClient.post<PendingWeChatCompletion>('/auth/oauth/wechat/complete-registration', { await apiClient.post<PendingWeChatCompletion>('/auth/oauth/wechat/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value, pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(), invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision()) ...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
}) })
).data ).data
: await completeWeChatOAuthRegistration( : affCode
invitationCode.value.trim(), ? await completeWeChatOAuthRegistration(invitationCode.value.trim(), decision, affCode)
currentAdoptionDecision() : await completeWeChatOAuthRegistration(invitationCode.value.trim(), decision)
)
await finalizePendingAccountResponse(completion) await finalizePendingAccountResponse(completion)
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } } const err = e as { message?: string; response?: { data?: { message?: string } } }
...@@ -907,6 +916,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) { ...@@ -907,6 +916,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password, password: payload.password,
verify_code: payload.verifyCode || undefined, verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined, invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision()) ...serializeAdoptionDecision(currentAdoptionDecision())
}) })
await finalizePendingAccountResponse(data) await finalizePendingAccountResponse(data)
...@@ -955,6 +965,7 @@ async function handleSubmitTotpChallenge() { ...@@ -955,6 +965,7 @@ async function handleSubmitTotpChallenge() {
}) })
persistOAuthTokenContext(completion) persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token) await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value) await router.replace(redirectTo.value)
} catch (e: unknown) { } catch (e: unknown) {
...@@ -1015,6 +1026,7 @@ onMounted(async () => { ...@@ -1015,6 +1026,7 @@ onMounted(async () => {
if (legacyLogin) { if (legacyLogin) {
persistOAuthTokenContext(legacyLogin) persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token) await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess')) appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect) await router.replace(redirect)
return return
......
...@@ -112,6 +112,7 @@ describe('EmailVerifyView', () => { ...@@ -112,6 +112,7 @@ describe('EmailVerifyView', () => {
apiClientPostMock.mockReset() apiClientPostMock.mockReset()
authStoreState.pendingAuthSession = null authStoreState.pendingAuthSession = null
sessionStorage.clear() sessionStorage.clear()
localStorage.clear()
getPublicSettingsMock.mockResolvedValue({ getPublicSettingsMock.mockResolvedValue({
turnstile_enabled: false, turnstile_enabled: false,
...@@ -136,6 +137,7 @@ describe('EmailVerifyView', () => { ...@@ -136,6 +137,7 @@ describe('EmailVerifyView', () => {
JSON.stringify({ JSON.stringify({
email: 'fresh@example.com', email: 'fresh@example.com',
password: 'secret-123', password: 'secret-123',
aff_code: 'AFF123',
}) })
) )
...@@ -334,6 +336,7 @@ describe('EmailVerifyView', () => { ...@@ -334,6 +336,7 @@ describe('EmailVerifyView', () => {
email: 'fresh@example.com', email: 'fresh@example.com',
password: 'secret-123', password: 'secret-123',
verify_code: '123456', verify_code: '123456',
aff_code: 'AFF123',
}) })
expect(persistOAuthTokenContextMock).toHaveBeenCalledWith({ expect(persistOAuthTokenContextMock).toHaveBeenCalledWith({
access_token: 'oauth-access-token', access_token: 'oauth-access-token',
......
...@@ -93,6 +93,7 @@ describe('LinuxDoCallbackView', () => { ...@@ -93,6 +93,7 @@ describe('LinuxDoCallbackView', () => {
}) })
window.location.hash = '' window.location.hash = ''
localStorage.clear() localStorage.clear()
sessionStorage.clear()
}) })
it('accepts the legacy fragment token success callback without pending-session exchange', async () => { it('accepts the legacy fragment token success callback without pending-session exchange', async () => {
......
...@@ -97,6 +97,7 @@ describe('OidcCallbackView', () => { ...@@ -97,6 +97,7 @@ describe('OidcCallbackView', () => {
}) })
window.location.hash = '' window.location.hash = ''
localStorage.clear() localStorage.clear()
sessionStorage.clear()
}) })
it('accepts the legacy fragment token success callback without pending-session exchange', async () => { it('accepts the legacy fragment token success callback without pending-session exchange', async () => {
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment