template_0205

This commit is contained in:
Sofio
2026-02-05 13:16:05 +08:00
commit d93e4d9c9f
197 changed files with 52810 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import { ApiErrorResponse } from "apisauce"
import { getGeneralApiProblem } from "./apiProblem"
test("handles connection errors", () => {
expect(getGeneralApiProblem({ problem: "CONNECTION_ERROR" } as ApiErrorResponse<null>)).toEqual({
kind: "cannot-connect",
temporary: true,
})
})
test("handles network errors", () => {
expect(getGeneralApiProblem({ problem: "NETWORK_ERROR" } as ApiErrorResponse<null>)).toEqual({
kind: "cannot-connect",
temporary: true,
})
})
test("handles timeouts", () => {
expect(getGeneralApiProblem({ problem: "TIMEOUT_ERROR" } as ApiErrorResponse<null>)).toEqual({
kind: "timeout",
temporary: true,
})
})
test("handles server errors", () => {
expect(getGeneralApiProblem({ problem: "SERVER_ERROR" } as ApiErrorResponse<null>)).toEqual({
kind: "server",
})
})
test("handles unknown errors", () => {
expect(getGeneralApiProblem({ problem: "UNKNOWN_ERROR" } as ApiErrorResponse<null>)).toEqual({
kind: "unknown",
temporary: true,
})
})
test("handles unauthorized errors", () => {
expect(
getGeneralApiProblem({ problem: "CLIENT_ERROR", status: 401 } as ApiErrorResponse<null>),
).toEqual({
kind: "unauthorized",
})
})
test("handles forbidden errors", () => {
expect(
getGeneralApiProblem({ problem: "CLIENT_ERROR", status: 403 } as ApiErrorResponse<null>),
).toEqual({
kind: "forbidden",
})
})
test("handles not-found errors", () => {
expect(
getGeneralApiProblem({ problem: "CLIENT_ERROR", status: 404 } as ApiErrorResponse<null>),
).toEqual({
kind: "not-found",
})
})
test("handles other client errors", () => {
expect(
getGeneralApiProblem({ problem: "CLIENT_ERROR", status: 418 } as ApiErrorResponse<null>),
).toEqual({
kind: "rejected",
})
})
test("handles cancellation errors", () => {
expect(getGeneralApiProblem({ problem: "CANCEL_ERROR" } as ApiErrorResponse<null>)).toBeNull()
})

View File

@@ -0,0 +1,103 @@
import { ApiResponse } from "apisauce"
// Base problem types without error details
type BaseProblem =
/**
* Times up.
*/
| { kind: "timeout"; temporary: true }
/**
* Cannot connect to the server for some reason.
*/
| { kind: "cannot-connect"; temporary: true }
/**
* The server experienced a problem. Any 5xx error.
*/
| { kind: "server" }
/**
* We're not allowed because we haven't identified ourself. This is 401.
*/
| { kind: "unauthorized" }
/**
* We don't have access to perform that request. This is 403.
*/
| { kind: "forbidden" }
/**
* Unable to find that resource. This is a 404.
*/
| { kind: "not-found" }
/**
* All other 4xx series errors.
*/
| { kind: "rejected" }
/**
* Something truly unexpected happened. Most likely can try again. This is a catch all.
*/
| { kind: "unknown"; temporary: true }
/**
* The data we received is not in the expected format.
*/
| { kind: "bad-data" }
// Extended problem type that includes optional error code and message from API response
export type GeneralApiProblem = BaseProblem & {
errorCode?: string
errorMessage?: string
}
/**
* Extracts error code from API response data.
* Handles both { code: "E001" } and { error: { code: "E001" } } formats.
*/
function extractErrorCode(data: any): string | undefined {
if (!data) return undefined
return data.code || data.error?.code
}
/**
* Extracts error message from API response data.
* Handles both { message: "..." } and { error: { message: "..." } } formats.
*/
function extractErrorMessage(data: any): string | undefined {
if (!data) return undefined
return data.message || data.error?.message
}
/**
* Attempts to get a common cause of problems from an api response.
*
* @param response The api response.
*/
export function getGeneralApiProblem(response: ApiResponse<any>): GeneralApiProblem | null {
// Extract error details from response data (if available)
const errorCode = extractErrorCode(response.data)
const errorMessage = extractErrorMessage(response.data)
switch (response.problem) {
case "CONNECTION_ERROR":
return { kind: "cannot-connect", temporary: true, errorCode, errorMessage }
case "NETWORK_ERROR":
return { kind: "cannot-connect", temporary: true, errorCode, errorMessage }
case "TIMEOUT_ERROR":
return { kind: "timeout", temporary: true, errorCode, errorMessage }
case "SERVER_ERROR":
return { kind: "server", errorCode, errorMessage }
case "UNKNOWN_ERROR":
return { kind: "unknown", temporary: true, errorCode, errorMessage }
case "CLIENT_ERROR":
switch (response.status) {
case 401:
return { kind: "unauthorized", errorCode, errorMessage }
case 403:
return { kind: "forbidden", errorCode, errorMessage }
case 404:
return { kind: "not-found", errorCode, errorMessage }
default:
return { kind: "rejected", errorCode, errorMessage }
}
case "CANCEL_ERROR":
return null
}
return null
}

View File

@@ -0,0 +1,878 @@
/**
* Authentication API Service
*
* Handles all authentication-related API calls:
* - Pre-login (password verification + send verification code)
* - Resend verification code
* - Verify login (code verification + get tokens)
* - Google login
*/
import { ApiResponse, ApisauceInstance, create } from "apisauce"
import Config from "@/config"
import { getGeneralApiProblem, GeneralApiProblem } from "./apiProblem"
import type {
BindNewEmailRequest,
BindNewEmailResponse,
ChangePasswordRequest,
ChangePasswordResponse,
ForgotPasswordRequest,
ForgotPasswordResponse,
GetLoginHistoryParams,
GetLoginHistoryResponse,
GetProfileResponse,
GetSessionCountResponse,
GetSessionsParams,
GetSessionsResponse,
GoogleLoginRequest,
GoogleLoginResponse,
LogoutResponse,
PreLoginRequest,
PreLoginResponse,
PreRegisterRequest,
PreRegisterResponse,
RefreshTokenRequest,
RefreshTokenResponse,
ResendCodeRequest,
ResendCodeResponse,
ResendForgotPasswordRequest,
ResendForgotPasswordResponse,
ResendRegisterCodeRequest,
ResendRegisterCodeResponse,
ResetPasswordRequest,
ResetPasswordResponse,
RevokeOtherSessionsResponse,
RevokeSessionResponse,
SendEmailCodeRequest,
SendEmailCodeResponse,
UpdateProfileRequest,
UpdateProfileResponse,
VerifyCurrentEmailRequest,
VerifyCurrentEmailResponse,
VerifyLoginRequest,
VerifyLoginResponse,
VerifyRegisterRequest,
VerifyRegisterResponse,
} from "./authTypes"
const AUTH_API_CONFIG = {
url: Config.AUTH_API_URL || "https://auth.upay01.com",
timeout: 15000,
}
/**
* Auth API class for handling authentication requests
*/
export class AuthApi {
apisauce: ApisauceInstance
constructor() {
this.apisauce = create({
baseURL: AUTH_API_CONFIG.url,
timeout: AUTH_API_CONFIG.timeout,
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
})
}
/**
* Pre-login: Verify password and send verification code
*/
async preLogin(
email: string,
password: string,
language?: string,
): Promise<{ kind: "ok"; data: PreLoginResponse } | GeneralApiProblem> {
const payload: PreLoginRequest = { email, password }
if (language) payload.language = language
const response: ApiResponse<PreLoginResponse> = await this.apisauce.post(
"/api/auth/pre-login",
payload,
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth preLogin error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Resend verification code
*/
async resendCode(
email: string,
language?: string,
): Promise<{ kind: "ok"; data: ResendCodeResponse } | GeneralApiProblem> {
const payload: ResendCodeRequest = { email }
if (language) payload.language = language
const response: ApiResponse<ResendCodeResponse> = await this.apisauce.post(
"/api/auth/pre-login/resend",
payload,
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth resendCode error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Verify login: Verify code and get tokens
*/
async verifyLogin(
email: string,
code: string,
): Promise<{ kind: "ok"; data: VerifyLoginResponse } | GeneralApiProblem> {
const payload: VerifyLoginRequest = { email, code }
const response: ApiResponse<VerifyLoginResponse> = await this.apisauce.post(
"/api/auth/login/verify",
payload,
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth verifyLogin error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Google login
*/
async googleLogin(
idToken: string,
referralCode?: string,
): Promise<{ kind: "ok"; data: GoogleLoginResponse } | GeneralApiProblem> {
const payload: GoogleLoginRequest = { idToken }
if (referralCode) payload.referralCode = referralCode
const response: ApiResponse<GoogleLoginResponse> = await this.apisauce.post(
"/api/auth/google-login",
payload,
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth googleLogin error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Pre-register: Validate data and send verification code
*/
async preRegister(
username: string,
password: string,
email: string,
nickname?: string,
referralCode?: string,
language?: string,
): Promise<{ kind: "ok"; data: PreRegisterResponse } | GeneralApiProblem> {
const payload: PreRegisterRequest = { username, password, email }
if (nickname) payload.nickname = nickname
if (referralCode) payload.referralCode = referralCode
if (language) payload.language = language
const response: ApiResponse<PreRegisterResponse> = await this.apisauce.post(
"/api/auth/pre-register",
payload,
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth preRegister error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Resend register verification code
*/
async resendRegisterCode(
email: string,
language?: string,
): Promise<{ kind: "ok"; data: ResendRegisterCodeResponse } | GeneralApiProblem> {
const payload: ResendRegisterCodeRequest = { email }
if (language) payload.language = language
const response: ApiResponse<ResendRegisterCodeResponse> = await this.apisauce.post(
"/api/auth/pre-register/resend",
payload,
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth resendRegisterCode error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Verify register: Verify code and create user
*/
async verifyRegister(
email: string,
code: string,
): Promise<{ kind: "ok"; data: VerifyRegisterResponse } | GeneralApiProblem> {
const payload: VerifyRegisterRequest = { email, code }
const response: ApiResponse<VerifyRegisterResponse> = await this.apisauce.post(
"/api/auth/register/verify",
payload,
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth verifyRegister error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Forgot password: Send verification code
*/
async forgotPassword(
email: string,
language?: string,
): Promise<{ kind: "ok"; data: ForgotPasswordResponse } | GeneralApiProblem> {
const payload: ForgotPasswordRequest = { email }
if (language) payload.language = language
const response: ApiResponse<ForgotPasswordResponse> = await this.apisauce.post(
"/api/auth/forgot-password",
payload,
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth forgotPassword error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Resend forgot password verification code
*/
async resendForgotPasswordCode(
email: string,
language?: string,
): Promise<{ kind: "ok"; data: ResendForgotPasswordResponse } | GeneralApiProblem> {
const payload: ResendForgotPasswordRequest = { email }
if (language) payload.language = language
const response: ApiResponse<ResendForgotPasswordResponse> = await this.apisauce.post(
"/api/auth/forgot-password/resend",
payload,
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth resendForgotPasswordCode error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Reset password: Verify code and set new password
*/
async resetPassword(
email: string,
code: string,
newPassword: string,
): Promise<{ kind: "ok"; data: ResetPasswordResponse } | GeneralApiProblem> {
const payload: ResetPasswordRequest = { email, code, newPassword }
const response: ApiResponse<ResetPasswordResponse> = await this.apisauce.post(
"/api/auth/reset-password",
payload,
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth resetPassword error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Logout: Revoke token
*/
async logoutApi(
accessToken: string,
): Promise<{ kind: "ok"; data: LogoutResponse } | GeneralApiProblem> {
const response: ApiResponse<LogoutResponse> = await this.apisauce.post(
"/api/auth/logout",
{},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth logout error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
// ============================================
// Profile APIs
// ============================================
/**
* Get user profile
*/
async getProfile(
accessToken: string,
): Promise<{ kind: "ok"; data: GetProfileResponse } | GeneralApiProblem> {
const response: ApiResponse<GetProfileResponse> = await this.apisauce.get(
"/api/auth/profile",
{},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth getProfile error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Update user profile
*/
async updateProfile(
accessToken: string,
payload: UpdateProfileRequest,
): Promise<{ kind: "ok"; data: UpdateProfileResponse } | GeneralApiProblem> {
const response: ApiResponse<UpdateProfileResponse> = await this.apisauce.put(
"/api/auth/profile",
payload,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth updateProfile error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
// ============================================
// Password APIs
// ============================================
/**
* Change password
*/
async changePassword(
accessToken: string,
payload: ChangePasswordRequest,
): Promise<{ kind: "ok"; data: ChangePasswordResponse } | GeneralApiProblem> {
const response: ApiResponse<ChangePasswordResponse> = await this.apisauce.post(
"/api/auth/change-password",
payload,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth changePassword error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
// ============================================
// Token APIs
// ============================================
/**
* Refresh access token
*/
async refreshToken(
refreshToken: string,
): Promise<{ kind: "ok"; data: RefreshTokenResponse } | GeneralApiProblem> {
const payload: RefreshTokenRequest = { refreshToken }
const response: ApiResponse<RefreshTokenResponse> = await this.apisauce.post(
"/api/auth/refresh-token",
payload,
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth refreshToken error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
// ============================================
// Email APIs
// ============================================
/**
* Send email verification code (for changing email)
*/
async sendEmailCode(
accessToken: string,
email: string,
language?: string,
): Promise<{ kind: "ok"; data: SendEmailCodeResponse } | GeneralApiProblem> {
const payload: SendEmailCodeRequest = { email }
if (language) payload.language = language
const response: ApiResponse<SendEmailCodeResponse> = await this.apisauce.post(
"/api/email/send-verification-code",
payload,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth sendEmailCode error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Verify current email (Step 1 of email change)
*/
async verifyCurrentEmail(
accessToken: string,
currentCode: string,
): Promise<{ kind: "ok"; data: VerifyCurrentEmailResponse } | GeneralApiProblem> {
const payload: VerifyCurrentEmailRequest = {
action: "verify-current",
currentCode,
}
const response: ApiResponse<VerifyCurrentEmailResponse> = await this.apisauce.post(
"/api/email/change",
payload,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth verifyCurrentEmail error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Bind new email (Step 2 of email change)
*/
async bindNewEmail(
accessToken: string,
newEmail: string,
newCode: string,
): Promise<{ kind: "ok"; data: BindNewEmailResponse } | GeneralApiProblem> {
const payload: BindNewEmailRequest = {
action: "bind-new",
newEmail,
newCode,
}
const response: ApiResponse<BindNewEmailResponse> = await this.apisauce.post(
"/api/email/change",
payload,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth bindNewEmail error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
// ============================================
// Session Management APIs
// ============================================
/**
* Get sessions/devices list
*/
async getSessions(
accessToken: string,
params?: GetSessionsParams,
): Promise<{ kind: "ok"; data: GetSessionsResponse } | GeneralApiProblem> {
const response: ApiResponse<GetSessionsResponse> = await this.apisauce.get(
"/api/sessions",
params,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth getSessions error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Get active session count
*/
async getSessionCount(
accessToken: string,
): Promise<{ kind: "ok"; data: GetSessionCountResponse } | GeneralApiProblem> {
const response: ApiResponse<GetSessionCountResponse> = await this.apisauce.get(
"/api/sessions/count",
{},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth getSessionCount error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Get login history
*/
async getLoginHistory(
accessToken: string,
params?: GetLoginHistoryParams,
): Promise<{ kind: "ok"; data: GetLoginHistoryResponse } | GeneralApiProblem> {
const response: ApiResponse<GetLoginHistoryResponse> = await this.apisauce.get(
"/api/sessions/history",
params,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth getLoginHistory error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Revoke a specific session (remote logout device)
*/
async revokeSession(
accessToken: string,
sessionId: string,
): Promise<{ kind: "ok"; data: RevokeSessionResponse } | GeneralApiProblem> {
const response: ApiResponse<RevokeSessionResponse> = await this.apisauce.delete(
`/api/sessions/${sessionId}`,
{},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth revokeSession error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
/**
* Revoke all other sessions (logout all other devices)
*/
async revokeOtherSessions(
accessToken: string,
): Promise<{ kind: "ok"; data: RevokeOtherSessionsResponse } | GeneralApiProblem> {
const response: ApiResponse<RevokeOtherSessionsResponse> = await this.apisauce.post(
"/api/sessions/revoke-others",
{},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
try {
const data = response.data
if (!data) return { kind: "bad-data" }
return { kind: "ok", data }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Auth revokeOtherSessions error: ${e.message}`)
}
return { kind: "bad-data" }
}
}
}
// Singleton instance
export const authApi = new AuthApi()

View File

@@ -0,0 +1,421 @@
/**
* Authentication API Types
*/
// Base API response with optional error code
export interface BaseApiResponse {
success: boolean
message?: string
code?: string // Error code at root level like E001, E011, etc.
error?: {
code?: string // Error code in nested error object
message?: string
}
}
// User profile
export interface UserProfile {
nickname: string
avatar: string
}
// User information
export interface User {
_id: string
userId: number
username: string
email: string
emailVerified: boolean
loginMethods: string[]
roles: string[]
profile: UserProfile
status: string
referralCode: string
referredBy?: string
referredByCode?: string
lastLogin: string
createdAt: string
updatedAt: string
}
// Tokens
export interface Tokens {
accessToken: string
refreshToken: string
accessTokenExpiry: string
refreshTokenExpiry: string
}
// Pre-login request
export interface PreLoginRequest {
email: string
password: string
language?: string
}
// Pre-login response
export interface PreLoginResponse extends BaseApiResponse {}
// Resend code request
export interface ResendCodeRequest {
email: string
language?: string
}
// Resend code response
export interface ResendCodeResponse extends BaseApiResponse {}
// Verify login request
export interface VerifyLoginRequest {
email: string
code: string
}
// Verify login response
export interface VerifyLoginResponse extends BaseApiResponse {
data?: {
user: User
tokens: Tokens
}
}
// Google login request
export interface GoogleLoginRequest {
idToken: string
referralCode?: string
}
// Google login response
export interface GoogleLoginResponse extends BaseApiResponse {
data?: {
user: User
tokens: Tokens
}
}
// Login step type
export type LoginStep = "credentials" | "verification"
// Register step type
export type RegisterStep = "credentials" | "verification"
// Pre-register request
export interface PreRegisterRequest {
username: string
password: string
email: string
nickname?: string
referralCode?: string
language?: string
}
// Pre-register response
export interface PreRegisterResponse extends BaseApiResponse {
data?: {
email: string
expiresIn: string
}
}
// Resend register code request
export interface ResendRegisterCodeRequest {
email: string
language?: string
}
// Resend register code response
export interface ResendRegisterCodeResponse extends BaseApiResponse {
data?: {
email: string
expiresIn: string
}
}
// Verify register request
export interface VerifyRegisterRequest {
email: string
code: string
}
// Verify register response
export interface VerifyRegisterResponse extends BaseApiResponse {
data?: {
user: User
tokens: Tokens
}
}
// Forgot password step type
export type ForgotPasswordStep = "email" | "reset"
// Forgot password request
export interface ForgotPasswordRequest {
email: string
language?: string
}
// Forgot password response
export interface ForgotPasswordResponse extends BaseApiResponse {
data?: {
email: string
expiresIn: number
}
}
// Resend forgot password code request
export interface ResendForgotPasswordRequest {
email: string
language?: string
}
// Resend forgot password code response
export interface ResendForgotPasswordResponse extends BaseApiResponse {
data?: {
email: string
expiresIn: number
}
}
// Reset password request
export interface ResetPasswordRequest {
email: string
code: string
newPassword: string
}
// Reset password response
export interface ResetPasswordResponse extends BaseApiResponse {
data?: {
message: string
}
}
// Logout response
export interface LogoutResponse extends BaseApiResponse {}
// ============================================
// Profile API Types
// ============================================
// Get profile response
export interface GetProfileResponse extends BaseApiResponse {
data?: {
userID: number
username: string
email: string
profile: UserProfile
roles: string[]
referralCode: string
hasReferrer: boolean
referredByCode?: string
status: string
lastLogin: string
createdAt: string
}
}
// Update profile request
export interface UpdateProfileRequest {
nickname?: string
avatar?: string
}
// Update profile response
export interface UpdateProfileResponse extends BaseApiResponse {
data?: {
user: User
}
}
// ============================================
// Change Password API Types
// ============================================
// Change password request
export interface ChangePasswordRequest {
oldPassword: string
newPassword: string
logoutOtherDevices?: boolean
}
// Change password response
export interface ChangePasswordResponse extends BaseApiResponse {
data?: {
loggedOutOtherDevices: boolean
}
}
// ============================================
// Refresh Token API Types
// ============================================
// Refresh token request
export interface RefreshTokenRequest {
refreshToken: string
}
// Refresh token response
export interface RefreshTokenResponse extends BaseApiResponse {
data?: {
accessToken: string
accessTokenExpiry: string
}
}
// ============================================
// Email Change API Types
// ============================================
// Send email verification code request
export interface SendEmailCodeRequest {
email: string
language?: string
}
// Send email verification code response
export interface SendEmailCodeResponse extends BaseApiResponse {
data?: {
email: string
expiresIn: string
}
}
// Verify current email request (Step 1)
export interface VerifyCurrentEmailRequest {
action: "verify-current"
currentCode: string
}
// Verify current email response
export interface VerifyCurrentEmailResponse extends BaseApiResponse {
data?: {
currentEmailVerified: boolean
expiresIn: string
}
}
// Bind new email request (Step 2)
export interface BindNewEmailRequest {
action: "bind-new"
newEmail: string
newCode: string
}
// Bind new email response
export interface BindNewEmailResponse extends BaseApiResponse {
data?: {
user: User
}
}
// Union type for email change requests
export type EmailChangeRequest = VerifyCurrentEmailRequest | BindNewEmailRequest
// Union type for email change responses
export type EmailChangeResponse = VerifyCurrentEmailResponse | BindNewEmailResponse
// ============================================
// Session Management API Types
// ============================================
// Device info for session
export interface DeviceInfo {
deviceId?: string
deviceName?: string
deviceType: string
os: string
osVersion?: string
browser: string
browserVersion?: string
userAgent?: string
}
// Location info for session
export interface LocationInfo {
country?: string
city?: string
}
// Session item
export interface Session {
id: string
userId: string
tokenJti: string
deviceInfo: DeviceInfo
ipAddress: string
location?: LocationInfo
loginMethod: string
isActive: boolean
isCurrent: boolean
lastActiveAt: string
createdAt: string
expiresAt: string
}
// Get sessions request params
export interface GetSessionsParams {
page?: number
pageSize?: number
active?: boolean
}
// Get sessions response
export interface GetSessionsResponse extends BaseApiResponse {
data?: {
sessions: Session[]
total: number
page: number
pageSize: number
totalPages: number
}
}
// Get session count response
export interface GetSessionCountResponse extends BaseApiResponse {
data?: {
activeCount: number
}
}
// Login history record
export interface LoginHistoryRecord {
id: string
userId: string
sessionId?: string
deviceInfo: DeviceInfo
ipAddress: string
location?: LocationInfo
loginMethod: string
status: "success" | "failed"
createdAt: string
}
// Get login history params
export interface GetLoginHistoryParams {
page?: number
pageSize?: number
status?: "success" | "failed"
startDate?: string
endDate?: string
}
// Get login history response
export interface GetLoginHistoryResponse extends BaseApiResponse {
data?: {
records: LoginHistoryRecord[]
total: number
page: number
pageSize: number
totalPages: number
}
}
// Revoke session response
export interface RevokeSessionResponse extends BaseApiResponse {}
// Revoke other sessions response
export interface RevokeOtherSessionsResponse extends BaseApiResponse {
data?: {
revokedCount: number
}
}

View File

@@ -0,0 +1,82 @@
/**
* This Api class lets you define an API endpoint and methods to request
* data and process it.
*
* See the [Backend API Integration](https://docs.infinite.red/ignite-cli/boilerplate/app/services/#backend-api-integration)
* documentation for more details.
*/
import { ApiResponse, ApisauceInstance, create } from "apisauce"
import Config from "@/config"
import type { EpisodeItem } from "@/services/api/types"
import { GeneralApiProblem, getGeneralApiProblem } from "./apiProblem"
import type { ApiConfig, ApiFeedResponse } from "./types"
/**
* Configuring the apisauce instance.
*/
export const DEFAULT_API_CONFIG: ApiConfig = {
url: Config.API_URL,
timeout: 10000,
}
/**
* Manages all requests to the API. You can use this class to build out
* various requests that you need to call from your backend API.
*/
export class Api {
apisauce: ApisauceInstance
config: ApiConfig
/**
* Set up our API instance. Keep this lightweight!
*/
constructor(config: ApiConfig = DEFAULT_API_CONFIG) {
this.config = config
this.apisauce = create({
baseURL: this.config.url,
timeout: this.config.timeout,
headers: {
Accept: "application/json",
},
})
}
/**
* Gets a list of recent React Native Radio episodes.
*/
async getEpisodes(): Promise<{ kind: "ok"; episodes: EpisodeItem[] } | GeneralApiProblem> {
// make the api call
const response: ApiResponse<ApiFeedResponse> = await this.apisauce.get(
`api.json?rss_url=https%3A%2F%2Ffeeds.simplecast.com%2FhEI_f9Dx`,
)
// the typical ways to die when calling an api
if (!response.ok) {
const problem = getGeneralApiProblem(response)
if (problem) return problem
}
// transform the data into the format we are expecting
try {
const rawData = response.data
// This is where we transform the data into the shape we expect for our model.
const episodes: EpisodeItem[] =
rawData?.items.map((raw) => ({
...raw,
})) ?? []
return { kind: "ok", episodes }
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Bad data: ${e.message}\n${response.data}`, e.stack)
}
return { kind: "bad-data" }
}
}
}
// Singleton instance of the API for convenience
export const api = new Api()

View File

@@ -0,0 +1,50 @@
/**
* These types indicate the shape of the data you expect to receive from your
* API endpoint, assuming it's a JSON object like we have.
*/
export interface EpisodeItem {
title: string
pubDate: string
link: string
guid: string
author: string
thumbnail: string
description: string
content: string
enclosure: {
link: string
type: string
length: number
duration: number
rating: { scheme: string; value: string }
}
categories: string[]
}
export interface ApiFeedResponse {
status: string
feed: {
url: string
title: string
link: string
author: string
description: string
image: string
}
items: EpisodeItem[]
}
/**
* The options used to configure apisauce.
*/
export interface ApiConfig {
/**
* The URL of the api.
*/
url: string
/**
* Milliseconds before we timeout the request.
*/
timeout: number
}

View File

@@ -0,0 +1,150 @@
/**
* Upload API for file uploads via proxy to MinIO
* Supports both Web and Native platforms
*/
import { Platform } from "react-native"
const OSS_BASE_URL = "https://oss.upay01.com"
// Upload response type
export interface UploadResponse {
code: number
message: string
data?: {
filename: string
originalName: string
size: number
mimetype: string
url: string
}
}
/**
* Convert a URI to Blob (for Web platform)
*/
async function uriToBlob(uri: string): Promise<Blob> {
const response = await fetch(uri)
const blob = await response.blob()
return blob
}
/**
* Upload file on Web platform
*/
async function uploadFileWeb(
uri: string,
fileName: string,
mimeType: string,
): Promise<{ kind: "ok"; url: string } | { kind: "error"; message: string }> {
try {
// Convert URI to Blob for Web
const blob = await uriToBlob(uri)
const file = new File([blob], fileName, { type: mimeType })
const formData = new FormData()
formData.append("file", file)
const response = await fetch(`${OSS_BASE_URL}/api/proxy/upload`, {
method: "POST",
body: formData,
})
const data: UploadResponse = await response.json()
if (response.ok && data.code === 200 && data.data?.url) {
return { kind: "ok", url: data.data.url }
} else {
return { kind: "error", message: data.message || "Upload failed" }
}
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Upload error: ${e.message}`)
}
return { kind: "error", message: "Upload failed" }
}
}
/**
* Upload file on Native platform (iOS/Android)
*/
function uploadFileNative(
uri: string,
fileName: string,
mimeType: string,
): Promise<{ kind: "ok"; url: string } | { kind: "error"; message: string }> {
return new Promise((resolve) => {
try {
const xhr = new XMLHttpRequest()
xhr.onload = () => {
if (__DEV__) {
console.log("Upload response status:", xhr.status)
console.log("Upload response body:", xhr.responseText)
}
if (xhr.status === 200) {
try {
const response: UploadResponse = JSON.parse(xhr.responseText)
if (response.code === 200 && response.data?.url) {
resolve({ kind: "ok", url: response.data.url })
} else {
resolve({ kind: "error", message: response.message || "Upload failed" })
}
} catch {
resolve({ kind: "error", message: "Invalid response" })
}
} else {
if (__DEV__) {
console.log("Upload failed - Status:", xhr.status, "Response:", xhr.responseText)
}
resolve({ kind: "error", message: `Upload failed with status ${xhr.status}` })
}
}
xhr.onerror = () => {
resolve({ kind: "error", message: "Network error" })
}
xhr.ontimeout = () => {
resolve({ kind: "error", message: "Upload timeout" })
}
xhr.open("POST", `${OSS_BASE_URL}/api/proxy/upload`)
xhr.timeout = 60000
// React Native specific FormData format
const formData = new FormData()
formData.append("file", {
uri: uri,
type: mimeType,
name: fileName,
} as any)
xhr.send(formData)
} catch (e) {
if (__DEV__ && e instanceof Error) {
console.error(`Upload error: ${e.message}`)
}
resolve({ kind: "error", message: "Upload failed" })
}
})
}
/**
* Upload a file to OSS storage
* Automatically handles Web vs Native platform differences
* @param uri - Local file URI
* @param fileName - File name
* @param mimeType - MIME type of the file
* @returns Upload result with URL or error
*/
export async function uploadFile(
uri: string,
fileName: string,
mimeType: string,
): Promise<{ kind: "ok"; url: string } | { kind: "error"; message: string }> {
if (Platform.OS === "web") {
return uploadFileWeb(uri, fileName, mimeType)
} else {
return uploadFileNative(uri, fileName, mimeType)
}
}