mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
399 lines
13 KiB
Swift
399 lines
13 KiB
Swift
import Foundation
|
|
|
|
// Shared Codable models mirroring the Go json tags exactly.
|
|
// Convention: NO convertFromSnakeCase; every model declares explicit CodingKeys.
|
|
|
|
// MARK: - Tokens
|
|
|
|
struct AuthToken: Codable, Sendable {
|
|
var accessToken: String
|
|
var accessTokenExpiresAt: Date
|
|
var refreshToken: String
|
|
var refreshTokenExpiresAt: Date
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case accessToken = "access_token"
|
|
case accessTokenExpiresAt = "access_token_expires_at"
|
|
case refreshToken = "refresh_token"
|
|
case refreshTokenExpiresAt = "refresh_token_expires_at"
|
|
}
|
|
}
|
|
|
|
/// `POST /auth/login/confirm` result: either the token pair or a 2FA challenge.
|
|
struct LoginResult: Codable, Sendable {
|
|
var accessToken: String?
|
|
var accessTokenExpiresAt: Date?
|
|
var refreshToken: String?
|
|
var refreshTokenExpiresAt: Date?
|
|
var twoFARequired: Bool?
|
|
var pendingToken: String?
|
|
var expiresIn: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case accessToken = "access_token"
|
|
case accessTokenExpiresAt = "access_token_expires_at"
|
|
case refreshToken = "refresh_token"
|
|
case refreshTokenExpiresAt = "refresh_token_expires_at"
|
|
case twoFARequired = "two_fa_required"
|
|
case pendingToken = "pending_token"
|
|
case expiresIn = "expires_in"
|
|
}
|
|
|
|
var token: AuthToken? {
|
|
guard let accessToken, let accessTokenExpiresAt, let refreshToken, let refreshTokenExpiresAt else { return nil }
|
|
return AuthToken(
|
|
accessToken: accessToken,
|
|
accessTokenExpiresAt: accessTokenExpiresAt,
|
|
refreshToken: refreshToken,
|
|
refreshTokenExpiresAt: refreshTokenExpiresAt
|
|
)
|
|
}
|
|
}
|
|
|
|
/// `POST /auth/login` and `/auth/register`.
|
|
///
|
|
/// `session` plus `codeRequired == true` is the two-step flow: a code was
|
|
/// emailed and the caller confirms it. When the deployment has the login code
|
|
/// off, or email verification off, nothing is sent and the remaining fields
|
|
/// carry the outcome instead, so `session` is optional.
|
|
struct AuthSession: Codable, Sendable {
|
|
var session: String?
|
|
var codeRequired: Bool?
|
|
var token: AuthToken?
|
|
var twoFARequired: Bool?
|
|
var pendingToken: String?
|
|
var expiresIn: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case session
|
|
case codeRequired = "code_required"
|
|
case token
|
|
case twoFARequired = "two_fa_required"
|
|
case pendingToken = "pending_token"
|
|
case expiresIn = "expires_in"
|
|
}
|
|
|
|
/// Older backends omit the field entirely; they always sent a code.
|
|
var needsCode: Bool { codeRequired ?? true }
|
|
}
|
|
|
|
/// `GET /auth/providers`: which native social sign-in options this host
|
|
/// supports. One shipped binary adapts to hosted and self-hosted backends.
|
|
struct AuthProvidersInfo: Codable, Sendable {
|
|
struct Provider: Codable, Sendable {
|
|
var enabled: Bool
|
|
var clientID: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case enabled
|
|
case clientID = "client_id"
|
|
}
|
|
}
|
|
|
|
var apple: Provider?
|
|
var google: Provider?
|
|
|
|
var appleEnabled: Bool { apple?.enabled == true }
|
|
var googleClientID: String? {
|
|
guard google?.enabled == true, let id = google?.clientID, !id.isEmpty else { return nil }
|
|
return id
|
|
}
|
|
}
|
|
|
|
// MARK: - Errors
|
|
|
|
struct APIErrorPayload: Codable, Sendable {
|
|
var error: String?
|
|
var message: String?
|
|
var code: String?
|
|
var requestId: String?
|
|
var retryAfterMs: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case error, message, code
|
|
case requestId = "request_id"
|
|
case retryAfterMs = "retry_after_ms"
|
|
}
|
|
}
|
|
|
|
enum APIError: Error, LocalizedError {
|
|
case notConfigured
|
|
case unauthorized(APIErrorPayload?)
|
|
case server(status: Int, payload: APIErrorPayload?)
|
|
case rateLimited(retryAfterMs: Int?)
|
|
case network(Error)
|
|
case decoding(Error)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notConfigured:
|
|
return "Server address is not configured."
|
|
case let .unauthorized(payload):
|
|
return payload?.message ?? "Your session has expired. Please sign in again."
|
|
case let .server(status, payload):
|
|
return payload?.message ?? "The server returned an error (\(status))."
|
|
case .rateLimited:
|
|
return "Too many requests. Please wait a moment and try again."
|
|
case .network:
|
|
return "Couldn't reach the server. Check your connection."
|
|
case .decoding:
|
|
return "The server response couldn't be read."
|
|
}
|
|
}
|
|
|
|
var code: String? {
|
|
switch self {
|
|
case let .unauthorized(payload): return payload?.code
|
|
case let .server(_, payload): return payload?.code
|
|
case .rateLimited: return "rate_limit_exceeded"
|
|
default: return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Pagination
|
|
|
|
struct Pagination: Codable, Sendable {
|
|
var total: Int64?
|
|
var nextCursor: String?
|
|
var hasMore: Bool?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case total
|
|
case nextCursor = "next_cursor"
|
|
case hasMore = "has_more"
|
|
}
|
|
}
|
|
|
|
struct ListResponse<T: Codable & Sendable>: Codable, Sendable {
|
|
var data: [T]
|
|
var pagination: Pagination?
|
|
}
|
|
|
|
struct MessageResponse: Codable, Sendable {
|
|
var message: String?
|
|
}
|
|
|
|
struct EmptyBody: Codable, Sendable {}
|
|
|
|
// MARK: - Permissions
|
|
|
|
/// `uint16` bitmask serialized as a JSON number; owner = 65535.
|
|
struct OrgPermissions: OptionSet, Sendable, Codable {
|
|
let rawValue: UInt16
|
|
|
|
static let manageTeam = OrgPermissions(rawValue: 1)
|
|
static let manageBilling = OrgPermissions(rawValue: 2)
|
|
static let manageCampaigns = OrgPermissions(rawValue: 4)
|
|
static let manageContacts = OrgPermissions(rawValue: 8)
|
|
static let manageEmails = OrgPermissions(rawValue: 16)
|
|
static let viewAnalytics = OrgPermissions(rawValue: 32)
|
|
static let sendCampaigns = OrgPermissions(rawValue: 64)
|
|
static let accessUnibox = OrgPermissions(rawValue: 128)
|
|
static let manageSequences = OrgPermissions(rawValue: 256)
|
|
static let manageSettings = OrgPermissions(rawValue: 512)
|
|
static let viewCampaigns = OrgPermissions(rawValue: 1024)
|
|
static let viewContacts = OrgPermissions(rawValue: 2048)
|
|
static let transferOwnership = OrgPermissions(rawValue: 4096)
|
|
static let manageAPIKeys = OrgPermissions(rawValue: 8192)
|
|
static let useIntegrations = OrgPermissions(rawValue: 16384)
|
|
static let useAI = OrgPermissions(rawValue: 32768)
|
|
|
|
static let owner = OrgPermissions(rawValue: .max)
|
|
}
|
|
|
|
// MARK: - User
|
|
|
|
/// Per-user label group (folders/tags/categories hydrated on GET /auth/me).
|
|
/// Go `models.Group` puts the display name on the wire as `title`.
|
|
struct UserGroup: Codable, Identifiable, Sendable, Hashable {
|
|
var id: String
|
|
var name: String?
|
|
var color: String?
|
|
var position: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, color, position
|
|
case name = "title"
|
|
}
|
|
}
|
|
|
|
struct User: Codable, Identifiable, Sendable {
|
|
var id: String
|
|
var firstName: String?
|
|
var lastName: String?
|
|
var email: String
|
|
var avatarURL: String?
|
|
var onboardingCompletedAt: Date?
|
|
var maxOrganizations: Int?
|
|
var freeTrialUsed: Bool?
|
|
var isAdmin: Bool?
|
|
var deletionScheduledFor: Date?
|
|
/// Undo window for instant sends; stale caches may omit it, default 30.
|
|
var undoSendSeconds: Int
|
|
var tags: [UserGroup]?
|
|
var categories: [UserGroup]?
|
|
var folders: [UserGroup]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, email, tags, categories, folders
|
|
case firstName = "first_name"
|
|
case lastName = "last_name"
|
|
case avatarURL = "avatar_url"
|
|
case onboardingCompletedAt = "onboarding_completed_at"
|
|
case maxOrganizations = "max_organizations"
|
|
case freeTrialUsed = "free_trial_used"
|
|
case isAdmin = "is_admin"
|
|
case deletionScheduledFor = "deletion_scheduled_for"
|
|
case undoSendSeconds = "undo_send_seconds"
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try container.decode(String.self, forKey: .id)
|
|
email = try container.decode(String.self, forKey: .email)
|
|
firstName = try container.decodeIfPresent(String.self, forKey: .firstName)
|
|
lastName = try container.decodeIfPresent(String.self, forKey: .lastName)
|
|
avatarURL = try container.decodeIfPresent(String.self, forKey: .avatarURL)
|
|
onboardingCompletedAt = try container.decodeIfPresent(Date.self, forKey: .onboardingCompletedAt)
|
|
maxOrganizations = try container.decodeIfPresent(Int.self, forKey: .maxOrganizations)
|
|
freeTrialUsed = try container.decodeIfPresent(Bool.self, forKey: .freeTrialUsed)
|
|
isAdmin = try container.decodeIfPresent(Bool.self, forKey: .isAdmin)
|
|
deletionScheduledFor = try container.decodeIfPresent(Date.self, forKey: .deletionScheduledFor)
|
|
undoSendSeconds = try container.decodeIfPresent(Int.self, forKey: .undoSendSeconds) ?? 30
|
|
tags = try container.decodeIfPresent([UserGroup].self, forKey: .tags)
|
|
categories = try container.decodeIfPresent([UserGroup].self, forKey: .categories)
|
|
folders = try container.decodeIfPresent([UserGroup].self, forKey: .folders)
|
|
}
|
|
|
|
var displayName: String {
|
|
let name = [firstName, lastName].compactMap { $0 }.joined(separator: " ")
|
|
return name.isEmpty ? email : name
|
|
}
|
|
|
|
var initials: String {
|
|
let first = firstName?.first.map(String.init) ?? ""
|
|
let last = lastName?.first.map(String.init) ?? ""
|
|
let combined = first + last
|
|
return combined.isEmpty ? String(email.prefix(1)).uppercased() : combined.uppercased()
|
|
}
|
|
}
|
|
|
|
// MARK: - Organization
|
|
|
|
struct Organization: Codable, Identifiable, Sendable, Hashable {
|
|
var id: String
|
|
var name: String
|
|
var slug: String?
|
|
var avatarURL: String?
|
|
var ownerUserID: String?
|
|
var createdAt: Date?
|
|
var deletionScheduledFor: Date?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, name, slug
|
|
case avatarURL = "avatar_url"
|
|
case ownerUserID = "owner_user_id"
|
|
case createdAt = "created_at"
|
|
case deletionScheduledFor = "deletion_scheduled_for"
|
|
}
|
|
}
|
|
|
|
struct OrganizationMember: Codable, Identifiable, Sendable {
|
|
var id: String
|
|
var organizationID: String
|
|
var userID: String
|
|
var role: String?
|
|
var permissions: UInt16?
|
|
var invitedAt: Date?
|
|
var acceptedAt: Date?
|
|
var organization: Organization?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, role, permissions, organization
|
|
case organizationID = "organization_id"
|
|
case userID = "user_id"
|
|
case invitedAt = "invited_at"
|
|
case acceptedAt = "accepted_at"
|
|
}
|
|
|
|
var orgPermissions: OrgPermissions { OrgPermissions(rawValue: permissions ?? 0) }
|
|
}
|
|
|
|
struct OrgLimits: Codable, Sendable {
|
|
var maxCampaigns: Int?
|
|
var maxActiveCampaigns: Int?
|
|
var maxTeamMembers: Int?
|
|
var maxEmailAccounts: Int?
|
|
var maxContacts: Int?
|
|
var dailyCampaignLimit: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case maxCampaigns = "max_campaigns"
|
|
case maxActiveCampaigns = "max_active_campaigns"
|
|
case maxTeamMembers = "max_team_members"
|
|
case maxEmailAccounts = "max_email_accounts"
|
|
case maxContacts = "max_contacts"
|
|
case dailyCampaignLimit = "daily_campaign_limit"
|
|
}
|
|
}
|
|
|
|
struct OrgCounts: Codable, Sendable {
|
|
var totalCampaigns: Int?
|
|
var activeCampaigns: Int?
|
|
var totalContacts: Int?
|
|
var totalMembers: Int?
|
|
var emailAccounts: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case totalCampaigns = "total_campaigns"
|
|
case activeCampaigns = "active_campaigns"
|
|
case totalContacts = "total_contacts"
|
|
case totalMembers = "total_members"
|
|
case emailAccounts = "email_accounts"
|
|
}
|
|
}
|
|
|
|
struct OrganizationCurrent: Codable, Sendable {
|
|
var id: String
|
|
var name: String
|
|
var slug: String?
|
|
var avatarURL: String?
|
|
var ownerUserID: String?
|
|
var presenceShowOnline: Bool?
|
|
var presenceShowActivity: Bool?
|
|
var limits: OrgLimits?
|
|
var counts: OrgCounts?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, name, slug, limits, counts
|
|
case avatarURL = "avatar_url"
|
|
case ownerUserID = "owner_user_id"
|
|
case presenceShowOnline = "presence_show_online"
|
|
case presenceShowActivity = "presence_show_activity"
|
|
}
|
|
}
|
|
|
|
struct SwitchOrgResponse: Codable, Sendable {
|
|
var message: String?
|
|
var organizationID: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case message
|
|
case organizationID = "organization_id"
|
|
}
|
|
}
|
|
|
|
// MARK: - Realtime bootstrap
|
|
|
|
struct GetSocket: Codable, Sendable {
|
|
var url: String
|
|
/// Seconds; Go float64.
|
|
var expiresIn: Double?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case url
|
|
case expiresIn = "expires_in"
|
|
}
|
|
}
|