
Account Deletion
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates an Apple-compliant in-app account deletion flow with multi-step confirmation, optional data export, grace period, Keychain cleanup, and Sign in with Apple token revocation.
About
Generates a production account deletion flow meeting Apple's in-app deletion requirement, including confirmation UI, grace period, and server-side deletion request. A developer uses it to satisfy App Review or GDPR right-to-erasure for an iOS/macOS app.
- Multi-step confirmation UI with optional data export and configurable grace period
- Sign in with Apple token revocation and Keychain cleanup
Account Deletion by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #887 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill account-deletionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates an Apple-compliant in-app account deletion flow with multi-step confirmation, optional data export, grace period, Keychain cleanup, and Sign in with Apple token revocation.
Files
Account Deletion Generator
Generate a production account deletion flow compliant with Apple's App Store requirement (effective June 30, 2022) that any app offering account creation must also offer account deletion from within the app. Includes multi-step confirmation UI, optional data export, configurable grace period, Keychain cleanup, and Sign in with Apple token revocation.
When This Skill Activates
Use this skill when the user:
- Asks to "add account deletion" or "delete account"
- Wants to "remove account" or implement "account removal"
- Mentions "right to delete" or "user data deletion"
- Asks about "Apple account deletion requirement"
- Needs App Store compliance for account management
- Wants to implement GDPR/privacy right-to-erasure
Pre-Generation Checks
1. Project Context Detection
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Check deployment target (iOS 16+ / macOS 13+)
- [ ] Check for @Observable support (iOS 17+ / macOS 14+)
- [ ] Identify source file locations
2. Existing Auth/Account Code
Search for existing account management:
Glob: **/*Auth*.swift, **/*Account*.swift, **/*User*.swift, **/*Profile*.swift
Grep: "ASAuthorizationAppleIDProvider" or "SignInWithApple" or "Keychain" or "deleteAccount"If existing deletion flow found:
- Ask if user wants to replace or enhance it
- If enhancing, integrate with existing auth architecture
3. Keychain Usage Detection
Grep: "SecItemAdd" or "SecItemDelete" or "SecItemCopyMatching" or "KeychainWrapper" or "keychain"If Keychain usage found, ensure cleanup covers all stored items.
4. CloudKit / Server Sync Detection
Grep: "CKContainer" or "CloudKit" or "CKRecord" or "NSPersistentCloudKitContainer"
Glob: **/*CloudKit*.swift, **/*Sync*.swiftIf CloudKit or server sync found, include remote data cleanup steps.
Configuration Questions
Ask user via AskUserQuestion:
1. Deletion type?
- Immediate — account deleted right away after confirmation
- Grace period — account scheduled for deletion, user can cancel
2. Grace period duration? (if grace period selected)
- 7 days
- 14 days — recommended
- 30 days
3. Include data export before deletion?
- Yes — generate DataExportService with JSON/ZIP archive and ShareLink
- No — skip data export
4. Server-side API call needed?
- Yes — generate server deletion request with configurable endpoint
- No — local-only deletion (Keychain, UserDefaults, SwiftData/CoreData, files)
Generation Process
Step 1: Read Templates
Read templates.md for production Swift code.
Step 2: Create Core Files
Generate these files: 1. AccountDeletionManager.swift — @Observable orchestrator for the full deletion lifecycle 2. DeletionConfirmationView.swift — Multi-step confirmation UI with NavigationStack 3. KeychainCleanup.swift — Utility to remove all app Keychain items
Step 3: Create Optional Files
Based on configuration:
DataExportService.swift— If data export selectedDeletionGracePeriodView.swift— If grace period selectedSignInWithAppleRevocation.swift— If SIWA detected in project
Step 4: Determine File Location
Check project structure:
- If
Sources/exists ->Sources/AccountDeletion/ - If
App/exists ->App/AccountDeletion/ - Otherwise ->
AccountDeletion/
Output Format
After generation, provide:
Files Created
AccountDeletion/
├── AccountDeletionManager.swift # Orchestrator for deletion lifecycle
├── DeletionConfirmationView.swift # Multi-step confirmation UI
├── KeychainCleanup.swift # Keychain item cleanup
├── DataExportService.swift # Data export before deletion (optional)
├── DeletionGracePeriodView.swift # Grace period countdown UI (optional)
└── SignInWithAppleRevocation.swift # SIWA token revocation (optional)Integration Steps
Add to Settings or Account screen:
// In your Settings or Account view
struct AccountSettingsView: View {
@State private var showDeletionFlow = false
var body: some View {
Form {
// ... other settings ...
Section {
Button(role: .destructive) {
showDeletionFlow = true
} label: {
Label("Delete Account", systemImage: "person.crop.circle.badge.minus")
}
} footer: {
Text("Permanently removes your account and all associated data.")
}
}
.sheet(isPresented: $showDeletionFlow) {
DeletionConfirmationView()
}
}
}With grace period (check on app launch):
@main
struct MyApp: App {
@State private var deletionManager = AccountDeletionManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(deletionManager)
.task {
await deletionManager.checkPendingDeletion()
}
}
}
}With data export:
// User can export before deleting
DeletionConfirmationView()
.environment(DataExportService())Testing
@Test
func deletionFlowCompletesSuccessfully() async throws {
let manager = AccountDeletionManager(
serverClient: MockDeletionClient(),
keychainCleanup: MockKeychainCleanup()
)
try await manager.confirmWithReauthentication()
try await manager.executeDeletion()
#expect(manager.deletionState == .completed)
}
@Test
func gracePeriodCancellation() async throws {
let manager = AccountDeletionManager()
try await manager.scheduleDeletion(gracePeriodDays: 14)
#expect(manager.scheduledDeletionDate != nil)
try await manager.cancelScheduledDeletion()
#expect(manager.deletionState == .none)
#expect(manager.scheduledDeletionDate == nil)
}
@Test
func keychainItemsRemovedOnDeletion() async throws {
let cleanup = KeychainCleanup()
// Store a test item
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "test-account",
kSecValueData as String: Data("secret".utf8)
]
SecItemAdd(query as CFDictionary, nil)
// Delete all items
try cleanup.removeAllItems()
// Verify removal
let searchQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "test-account",
kSecReturnData as String: true
]
let status = SecItemCopyMatching(searchQuery as CFDictionary, nil)
#expect(status == errSecItemNotFound)
}
@Test
func dataExportGeneratesArchive() async throws {
let exportService = DataExportService()
let archiveURL = try await exportService.exportAllUserData()
#expect(FileManager.default.fileExists(atPath: archiveURL.path))
// Cleanup
try FileManager.default.removeItem(at: archiveURL)
}Common Patterns
Initiate Deletion from Settings
Account deletion must be accessible from within the app — typically in Settings > Account. Apple will reject apps that only offer deletion via website or email.
Confirm with Password or Biometric
Re-authenticate the user before deletion to prevent accidental or unauthorized account removal. Use LocalAuthentication for biometric or prompt for password.
Export Data Before Deletion
Offer users the ability to download their data before the account is removed. This is a privacy best practice and builds user trust.
Schedule Deletion with Grace Period
Instead of immediate deletion, schedule it for 7-30 days out. Allow users to cancel during this window. Many users delete accounts impulsively and appreciate the recovery option.
Gotchas
- Apple requires in-app deletion — App Review will reject apps where account deletion is only available via website, email, or contacting support. The option must be accessible from within the app itself.
- Keychain items persist after app uninstall — You must explicitly call
SecItemDeletefor all item classes (generic password, internet password, certificate, key, identity) during account deletion. - Sign in with Apple token revocation — If your app supports Sign in with Apple, you must revoke the user's token via Apple's REST API (
https://appleid.apple.com/auth/revoke). Failure to do so means Apple still considers the account linked. - CloudKit data cleanup — CloudKit private database records are tied to the user's iCloud account, not your app. You cannot delete them on behalf of the user. Document this limitation and delete any shared/public records your app created.
- Subscription cancellation guidance — Account deletion does not automatically cancel active subscriptions. Display a warning and link to Settings > Subscriptions so users can cancel before deletion.
- UserDefaults and app group data — Remember to clean
UserDefaults.standardand any shared app group containers (UserDefaults(suiteName:)). - SwiftData / CoreData cleanup — Delete all model containers and persistent stores, not just individual records.
References
- templates.md — All production Swift templates
- Related:
generators/auth-flow— Authentication flow generation - Related:
generators/persistence-setup— Data persistence that needs cleanup - Related:
generators/settings-screen— Settings screen where deletion is placed - Related:
generators/cloudkit-sync— CloudKit data that needs cleanup consideration
Account Deletion Code Templates
Production-ready Swift templates for Apple-compliant account deletion. All code targets iOS 16+ / macOS 13+ (iOS 17+ / macOS 14+ for @Observable) and uses modern Swift concurrency.
AccountDeletionManager.swift
import Foundation
import SwiftUI
/// Orchestrates the complete account deletion lifecycle.
///
/// Manages state transitions from initiation through confirmation,
/// optional data export, grace period scheduling, and final execution.
///
/// Usage:
/// ```swift
/// @State private var deletionManager = AccountDeletionManager()
///
/// Button("Delete Account") {
/// Task { await deletionManager.initiateDeletion() }
/// }
/// ```
@Observable
final class AccountDeletionManager {
private(set) var deletionState: DeletionState = .none
private(set) var scheduledDeletionDate: Date?
private(set) var isProcessing = false
private let serverClient: AccountDeletionClient?
private let keychainCleanup: KeychainCleanup
private let gracePeriodKey = "AccountDeletion.scheduledDate"
enum DeletionState: Sendable, Equatable {
case none
case confirming
case exporting
case scheduled(Date)
case deleting
case completed
case failed(String)
static func == (lhs: DeletionState, rhs: DeletionState) -> Bool {
switch (lhs, rhs) {
case (.none, .none), (.confirming, .confirming),
(.exporting, .exporting), (.deleting, .deleting),
(.completed, .completed):
return true
case (.scheduled(let a), .scheduled(let b)):
return a == b
case (.failed(let a), .failed(let b)):
return a == b
default:
return false
}
}
}
init(
serverClient: AccountDeletionClient? = nil,
keychainCleanup: KeychainCleanup = KeychainCleanup()
) {
self.serverClient = serverClient
self.keychainCleanup = keychainCleanup
// Restore any pending scheduled deletion
if let scheduledDate = UserDefaults.standard.object(forKey: gracePeriodKey) as? Date {
self.scheduledDeletionDate = scheduledDate
self.deletionState = .scheduled(scheduledDate)
}
}
/// Begin the deletion flow — transitions to confirming state.
func initiateDeletion() async {
deletionState = .confirming
}
/// Re-authenticate the user before proceeding with deletion.
///
/// Uses LocalAuthentication for biometric or falls back to password.
func confirmWithReauthentication() async throws {
isProcessing = true
defer { isProcessing = false }
// Biometric authentication
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
throw AccountDeletionError.authenticationUnavailable
}
let success = try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Confirm your identity to delete your account"
)
guard success else {
throw AccountDeletionError.authenticationFailed
}
}
/// Export all user data before deletion.
func exportUserData() async throws -> URL {
deletionState = .exporting
let exportService = DataExportService()
return try await exportService.exportAllUserData()
}
/// Schedule deletion after a grace period instead of immediate execution.
func scheduleDeletion(gracePeriodDays: Int) async throws {
isProcessing = true
defer { isProcessing = false }
let deletionDate = Calendar.current.date(
byAdding: .day,
value: gracePeriodDays,
to: Date()
)!
// Notify server of scheduled deletion
if let serverClient {
try await serverClient.scheduleDeletion(date: deletionDate)
}
// Persist locally
UserDefaults.standard.set(deletionDate, forKey: gracePeriodKey)
scheduledDeletionDate = deletionDate
deletionState = .scheduled(deletionDate)
}
/// Cancel a previously scheduled deletion.
func cancelScheduledDeletion() async throws {
isProcessing = true
defer { isProcessing = false }
// Notify server of cancellation
if let serverClient {
try await serverClient.cancelScheduledDeletion()
}
// Clear local state
UserDefaults.standard.removeObject(forKey: gracePeriodKey)
scheduledDeletionDate = nil
deletionState = .none
}
/// Execute the account deletion — cleans up all local and remote data.
///
/// This is the final, irreversible step. Call only after confirmation.
func executeDeletion() async throws {
isProcessing = true
deletionState = .deleting
do {
// 1. Server-side deletion (if configured)
if let serverClient {
try await serverClient.deleteAccount()
}
// 2. Revoke Sign in with Apple token (if applicable)
await revokeSignInWithAppleTokenIfNeeded()
// 3. Keychain cleanup
try keychainCleanup.removeAllItems()
// 4. UserDefaults cleanup
cleanUserDefaults()
// 5. File system cleanup
cleanFileSystem()
// 6. Clear scheduled deletion
UserDefaults.standard.removeObject(forKey: gracePeriodKey)
scheduledDeletionDate = nil
deletionState = .completed
isProcessing = false
} catch {
deletionState = .failed(error.localizedDescription)
isProcessing = false
throw error
}
}
/// Check for pending scheduled deletions on app launch.
///
/// Call this from your App's `.task` modifier.
func checkPendingDeletion() async {
guard let scheduledDate = scheduledDeletionDate else { return }
if Date() >= scheduledDate {
// Grace period expired — execute deletion
try? await executeDeletion()
}
}
// MARK: - Private Cleanup Methods
private func revokeSignInWithAppleTokenIfNeeded() async {
// Check if user signed in with Apple
guard let refreshToken = retrieveAppleRefreshToken() else { return }
let revocation = SignInWithAppleRevocation()
try? await revocation.revokeToken(
refreshToken: refreshToken,
clientID: Bundle.main.bundleIdentifier ?? "",
clientSecret: "" // Generate via server
)
}
private func retrieveAppleRefreshToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "apple_refresh_token",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
private func cleanUserDefaults() {
guard let bundleID = Bundle.main.bundleIdentifier else { return }
UserDefaults.standard.removePersistentDomain(forName: bundleID)
UserDefaults.standard.synchronize()
// Also clean any app group defaults
// UserDefaults(suiteName: "group.com.yourapp")?.removePersistentDomain(forName: "group.com.yourapp")
}
private func cleanFileSystem() {
let fileManager = FileManager.default
// Documents directory
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
try? fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
.forEach { try? fileManager.removeItem(at: $0) }
}
// Caches directory
if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first {
try? fileManager.contentsOfDirectory(at: cachesURL, includingPropertiesForKeys: nil)
.forEach { try? fileManager.removeItem(at: $0) }
}
// Application Support directory
if let appSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
try? fileManager.contentsOfDirectory(at: appSupportURL, includingPropertiesForKeys: nil)
.forEach { try? fileManager.removeItem(at: $0) }
}
}
}
// MARK: - Supporting Types
/// Protocol for server-side account deletion requests.
protocol AccountDeletionClient: Sendable {
func deleteAccount() async throws
func scheduleDeletion(date: Date) async throws
func cancelScheduledDeletion() async throws
}
/// Errors specific to account deletion.
enum AccountDeletionError: Error, LocalizedError {
case authenticationUnavailable
case authenticationFailed
case serverDeletionFailed(String)
case keychainCleanupFailed
case alreadyScheduled
var errorDescription: String? {
switch self {
case .authenticationUnavailable:
return "Authentication is not available on this device."
case .authenticationFailed:
return "Authentication failed. Please try again."
case .serverDeletionFailed(let reason):
return "Server deletion failed: \(reason)"
case .keychainCleanupFailed:
return "Failed to clean up stored credentials."
case .alreadyScheduled:
return "Account deletion is already scheduled."
}
}
}
// Required import for LocalAuthentication
import LocalAuthentication
import SecurityDeletionConfirmationView.swift
import SwiftUI
/// Multi-step account deletion confirmation flow.
///
/// Steps: Explain Consequences -> Optional Data Export -> Re-authenticate -> Confirm
///
/// Usage:
/// ```swift
/// .sheet(isPresented: $showDeletion) {
/// DeletionConfirmationView()
/// }
/// ```
struct DeletionConfirmationView: View {
@Environment(AccountDeletionManager.self) private var deletionManager
@Environment(\.dismiss) private var dismiss
@State private var currentStep: DeletionStep = .consequences
@State private var confirmationText = ""
@State private var includeDataExport = false
@State private var exportURL: URL?
@State private var errorMessage: String?
@State private var showError = false
enum DeletionStep: Int, CaseIterable {
case consequences
case dataExport
case reauthenticate
case finalConfirm
}
var body: some View {
NavigationStack {
VStack {
// Progress indicator
ProgressIndicatorView(
currentStep: currentStep.rawValue,
totalSteps: DeletionStep.allCases.count
)
.padding(.top)
// Step content
switch currentStep {
case .consequences:
ConsequencesStepView(
includeDataExport: $includeDataExport,
onContinue: { advanceStep() }
)
case .dataExport:
DataExportStepView(
exportURL: $exportURL,
onExport: { await exportData() },
onSkip: { advanceStep() }
)
case .reauthenticate:
ReauthenticateStepView(
onAuthenticate: { await reauthenticate() }
)
case .finalConfirm:
FinalConfirmStepView(
confirmationText: $confirmationText,
isProcessing: deletionManager.isProcessing,
onDelete: { await performDeletion() }
)
}
}
.navigationTitle("Delete Account")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
.disabled(deletionManager.isProcessing)
}
}
.alert("Error", isPresented: $showError) {
Button("OK") { }
} message: {
Text(errorMessage ?? "An unexpected error occurred.")
}
}
}
// MARK: - Step Navigation
private func advanceStep() {
withAnimation {
switch currentStep {
case .consequences:
currentStep = includeDataExport ? .dataExport : .reauthenticate
case .dataExport:
currentStep = .reauthenticate
case .reauthenticate:
currentStep = .finalConfirm
case .finalConfirm:
break
}
}
}
// MARK: - Actions
private func exportData() async {
do {
exportURL = try await deletionManager.exportUserData()
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
private func reauthenticate() async {
do {
try await deletionManager.confirmWithReauthentication()
advanceStep()
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
private func performDeletion() async {
do {
try await deletionManager.executeDeletion()
dismiss()
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
// MARK: - Step Views
private struct ConsequencesStepView: View {
@Binding var includeDataExport: Bool
let onContinue: () -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundStyle(.red)
.frame(maxWidth: .infinity)
Text("What happens when you delete your account")
.font(.headline)
VStack(alignment: .leading, spacing: 12) {
ConsequenceRow(
icon: "person.slash",
text: "Your account and profile will be permanently removed"
)
ConsequenceRow(
icon: "doc.text.fill",
text: "All your data, content, and history will be deleted"
)
ConsequenceRow(
icon: "key.fill",
text: "All saved credentials and tokens will be erased"
)
ConsequenceRow(
icon: "arrow.counterclockwise",
text: "This action cannot be undone"
)
}
Divider()
// Subscription warning
VStack(alignment: .leading, spacing: 8) {
Label("Active Subscriptions", systemImage: "creditcard.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
Text("Deleting your account does not cancel active subscriptions. Please cancel any subscriptions in Settings > Subscriptions before proceeding.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 10))
Toggle("Export my data before deletion", isOn: $includeDataExport)
.padding(.top, 8)
Button(action: onContinue) {
Text("Continue")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.large)
}
.padding()
}
}
}
private struct ConsequenceRow: View {
let icon: String
let text: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.foregroundStyle(.red)
.frame(width: 24)
Text(text)
.font(.subheadline)
}
}
}
private struct DataExportStepView: View {
@Binding var exportURL: URL?
let onExport: () async -> Void
let onSkip: () -> Void
@State private var isExporting = false
var body: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "square.and.arrow.down.fill")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Export Your Data")
.font(.title2.weight(.semibold))
Text("Download a copy of all your data before deleting your account.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
if let exportURL {
ShareLink(item: exportURL) {
Label("Share Export File", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal)
Button("Continue to Deletion") {
onSkip()
}
.buttonStyle(.bordered)
.controlSize(.large)
} else {
Button {
isExporting = true
Task {
await onExport()
isExporting = false
}
} label: {
if isExporting {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Label("Export My Data", systemImage: "arrow.down.circle.fill")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(isExporting)
.padding(.horizontal)
Button("Skip Export") {
onSkip()
}
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
private struct ReauthenticateStepView: View {
let onAuthenticate: () async -> Void
@State private var isAuthenticating = false
var body: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "faceid")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Verify Your Identity")
.font(.title2.weight(.semibold))
Text("For your security, please verify your identity before deleting your account.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Button {
isAuthenticating = true
Task {
await onAuthenticate()
isAuthenticating = false
}
} label: {
if isAuthenticating {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Label("Verify Identity", systemImage: "lock.shield.fill")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(isAuthenticating)
.padding(.horizontal)
Spacer()
}
}
}
private struct FinalConfirmStepView: View {
@Binding var confirmationText: String
let isProcessing: Bool
let onDelete: () async -> Void
private let requiredText = "DELETE"
var body: some View {
VStack(spacing: 24) {
Spacer()
Image(systemName: "trash.fill")
.font(.system(size: 48))
.foregroundStyle(.red)
Text("Final Confirmation")
.font(.title2.weight(.semibold))
Text("Type **DELETE** to confirm account deletion.")
.font(.subheadline)
.foregroundStyle(.secondary)
TextField("Type DELETE", text: $confirmationText)
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.center)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.characters)
#endif
.padding(.horizontal, 40)
Button {
Task { await onDelete() }
} label: {
if isProcessing {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text("Delete My Account Permanently")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(.red)
.controlSize(.large)
.disabled(confirmationText != requiredText || isProcessing)
.padding(.horizontal)
Spacer()
}
}
}
// MARK: - Progress Indicator
private struct ProgressIndicatorView: View {
let currentStep: Int
let totalSteps: Int
var body: some View {
HStack(spacing: 8) {
ForEach(0..<totalSteps, id: \.self) { step in
Capsule()
.fill(step <= currentStep ? Color.red : Color.secondary.opacity(0.3))
.frame(height: 4)
}
}
.padding(.horizontal)
}
}DataExportService.swift
import Foundation
import SwiftUI
/// Collects all user data and packages it into a JSON archive for download.
///
/// Gathers data from SwiftData/CoreData, UserDefaults, and the file system,
/// then produces a JSON file that can be shared via ShareLink.
///
/// Usage:
/// ```swift
/// let exportService = DataExportService()
/// let archiveURL = try await exportService.exportAllUserData()
/// // Present ShareLink with archiveURL
/// ```
@Observable
final class DataExportService {
private(set) var isExporting = false
private(set) var exportProgress: Double = 0
/// Export all user data to a JSON file in the temporary directory.
///
/// - Returns: URL to the exported JSON file.
func exportAllUserData() async throws -> URL {
isExporting = true
exportProgress = 0
defer { isExporting = false }
var exportData: [String: Any] = [:]
// 1. Export UserDefaults
exportProgress = 0.2
exportData["userDefaults"] = exportUserDefaults()
// 2. Export documents directory file list
exportProgress = 0.4
exportData["documents"] = exportDocumentsManifest()
// 3. Export app-specific data
// TODO: Add your SwiftData/CoreData model exports here
// Example:
// exportData["posts"] = try await exportPosts()
// exportData["comments"] = try await exportComments()
exportProgress = 0.7
// 4. Export metadata
exportData["exportMetadata"] = [
"exportDate": ISO8601DateFormatter().string(from: Date()),
"appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown",
"bundleIdentifier": Bundle.main.bundleIdentifier ?? "unknown"
]
exportProgress = 0.9
// 5. Serialize and write to temp file
let jsonData = try JSONSerialization.data(
withJSONObject: exportData,
options: [.prettyPrinted, .sortedKeys]
)
let fileName = "account-data-export-\(formattedDate()).json"
let exportURL = FileManager.default.temporaryDirectory
.appendingPathComponent(fileName)
try jsonData.write(to: exportURL)
exportProgress = 1.0
return exportURL
}
// MARK: - Private
private func exportUserDefaults() -> [String: Any] {
guard let bundleID = Bundle.main.bundleIdentifier else { return [:] }
return UserDefaults.standard.persistentDomain(forName: bundleID) ?? [:]
}
private func exportDocumentsManifest() -> [[String: String]] {
let fileManager = FileManager.default
guard let documentsURL = fileManager.urls(
for: .documentDirectory,
in: .userDomainMask
).first else { return [] }
let files = (try? fileManager.contentsOfDirectory(
at: documentsURL,
includingPropertiesForKeys: [.fileSizeKey, .creationDateKey],
options: .skipsHiddenFiles
)) ?? []
return files.map { url in
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
let created = (try? url.resourceValues(forKeys: [.creationDateKey]))?.creationDate
return [
"name": url.lastPathComponent,
"size": "\(size) bytes",
"created": created.map { ISO8601DateFormatter().string(from: $0) } ?? "unknown"
]
}
}
private func formattedDate() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: Date())
}
}DeletionGracePeriodView.swift
import SwiftUI
/// Displays the grace period countdown and option to cancel scheduled deletion.
///
/// Show this view when the user has a pending account deletion.
///
/// Usage:
/// ```swift
/// if case .scheduled(let date) = deletionManager.deletionState {
/// DeletionGracePeriodView(scheduledDate: date)
/// }
/// ```
struct DeletionGracePeriodView: View {
@Environment(AccountDeletionManager.self) private var deletionManager
let scheduledDate: Date
@State private var errorMessage: String?
@State private var showError = false
var body: some View {
VStack(spacing: 24) {
// Warning icon
Image(systemName: "clock.badge.exclamationmark.fill")
.font(.system(size: 56))
.foregroundStyle(.orange)
.symbolRenderingMode(.multicolor)
// Countdown
Text("Account Deletion Scheduled")
.font(.title2.weight(.semibold))
Text("Your account will be permanently deleted on:")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(scheduledDate, style: .date)
.font(.title3.weight(.medium))
.foregroundStyle(.red)
// Time remaining
TimeRemainingView(targetDate: scheduledDate)
Divider()
.padding(.horizontal)
// What happens
VStack(alignment: .leading, spacing: 12) {
Text("When the period expires:")
.font(.subheadline.weight(.semibold))
Label("All account data will be permanently deleted", systemImage: "trash")
.font(.subheadline)
.foregroundStyle(.secondary)
Label("All stored credentials will be removed", systemImage: "key.slash")
.font(.subheadline)
.foregroundStyle(.secondary)
Label("This action cannot be reversed", systemImage: "arrow.counterclockwise")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
Spacer()
// Cancel button
Button {
Task {
do {
try await deletionManager.cancelScheduledDeletion()
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
} label: {
Text("Cancel Account Deletion")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(deletionManager.isProcessing)
.padding(.horizontal)
.padding(.bottom)
}
.alert("Error", isPresented: $showError) {
Button("OK") { }
} message: {
Text(errorMessage ?? "Failed to cancel deletion.")
}
}
}
/// Displays a live countdown to the target date.
private struct TimeRemainingView: View {
let targetDate: Date
@State private var timeRemaining: String = ""
var body: some View {
Text(timeRemaining)
.font(.headline)
.foregroundStyle(.orange)
.monospacedDigit()
.onAppear { updateTimeRemaining() }
.task {
// Update every minute
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(60))
updateTimeRemaining()
}
}
}
private func updateTimeRemaining() {
let components = Calendar.current.dateComponents(
[.day, .hour, .minute],
from: Date(),
to: targetDate
)
let days = components.day ?? 0
let hours = components.hour ?? 0
let minutes = components.minute ?? 0
if days > 0 {
timeRemaining = "\(days) day\(days == 1 ? "" : "s"), \(hours) hour\(hours == 1 ? "" : "s") remaining"
} else if hours > 0 {
timeRemaining = "\(hours) hour\(hours == 1 ? "" : "s"), \(minutes) minute\(minutes == 1 ? "" : "s") remaining"
} else {
timeRemaining = "\(max(0, minutes)) minute\(minutes == 1 ? "" : "s") remaining"
}
}
}KeychainCleanup.swift
import Foundation
import Security
/// Utility to remove all app Keychain items during account deletion.
///
/// Handles SecItemDelete for all item classes:
/// - Generic passwords (kSecClassGenericPassword)
/// - Internet passwords (kSecClassInternetPassword)
/// - Certificates (kSecClassCertificate)
/// - Keys (kSecClassKey)
/// - Identities (kSecClassIdentity)
///
/// Usage:
/// ```swift
/// let cleanup = KeychainCleanup()
/// try cleanup.removeAllItems()
/// ```
struct KeychainCleanup: Sendable {
/// All Keychain item classes that need to be cleaned.
private static let itemClasses: [CFString] = [
kSecClassGenericPassword,
kSecClassInternetPassword,
kSecClassCertificate,
kSecClassKey,
kSecClassIdentity
]
/// Remove all Keychain items stored by this app.
///
/// Iterates through all item classes and deletes matching entries.
/// - Throws: `AccountDeletionError.keychainCleanupFailed` if any deletion fails
/// with an error other than `errSecItemNotFound`.
func removeAllItems() throws {
var errors: [OSStatus] = []
for itemClass in Self.itemClasses {
let query: [String: Any] = [
kSecClass as String: itemClass
]
let status = SecItemDelete(query as CFDictionary)
// errSecItemNotFound is fine — means nothing to delete
if status != errSecSuccess && status != errSecItemNotFound {
errors.append(status)
}
}
if !errors.isEmpty {
throw AccountDeletionError.keychainCleanupFailed
}
}
/// Remove a specific Keychain item by account name.
///
/// - Parameters:
/// - account: The account identifier (kSecAttrAccount value).
/// - itemClass: The Keychain item class. Defaults to generic password.
func removeItem(account: String, itemClass: CFString = kSecClassGenericPassword) throws {
let query: [String: Any] = [
kSecClass as String: itemClass,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw AccountDeletionError.keychainCleanupFailed
}
}
}SignInWithAppleRevocation.swift
import Foundation
import AuthenticationServices
/// Revokes a Sign in with Apple refresh token via Apple's REST API.
///
/// Required for compliance when deleting accounts that used Sign in with Apple.
/// Apple mandates that apps revoke tokens when a user deletes their account.
///
/// Reference: https://developer.apple.com/documentation/sign_in_with_apple/revoke_tokens
///
/// Usage:
/// ```swift
/// let revocation = SignInWithAppleRevocation()
/// try await revocation.revokeToken(
/// refreshToken: storedRefreshToken,
/// clientID: Bundle.main.bundleIdentifier!,
/// clientSecret: generatedClientSecret
/// )
/// ```
struct SignInWithAppleRevocation: Sendable {
private let revokeURL = URL(string: "https://appleid.apple.com/auth/revoke")!
/// Revoke a Sign in with Apple token.
///
/// - Parameters:
/// - refreshToken: The refresh token obtained during Sign in with Apple.
/// - clientID: Your app's bundle identifier (client_id).
/// - clientSecret: A JWT client secret generated server-side.
///
/// - Note: The client secret must be a JWT signed with your Sign in with Apple
/// private key. Generate this on your server, not in the client app.
func revokeToken(
refreshToken: String,
clientID: String,
clientSecret: String
) async throws {
var request = URLRequest(url: revokeURL)
request.httpMethod = "POST"
request.setValue(
"application/x-www-form-urlencoded",
forHTTPHeaderField: "Content-Type"
)
let parameters = [
"client_id": clientID,
"client_secret": clientSecret,
"token": refreshToken,
"token_type_hint": "refresh_token"
]
request.httpBody = parameters
.map { "\($0.key)=\($0.value)" }
.joined(separator: "&")
.data(using: .utf8)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SignInWithAppleRevocationError.invalidResponse
}
// 200 = success, 400 = invalid token (already revoked, which is fine)
guard httpResponse.statusCode == 200 || httpResponse.statusCode == 400 else {
throw SignInWithAppleRevocationError.revocationFailed(
statusCode: httpResponse.statusCode
)
}
}
/// Check if the current user's Apple ID credential is still valid.
///
/// Call this to determine if Sign in with Apple revocation is needed.
func checkCredentialState(userID: String) async -> ASAuthorizationAppleIDProvider.CredentialState {
await withCheckedContinuation { continuation in
ASAuthorizationAppleIDProvider().getCredentialState(forUserID: userID) { state, _ in
continuation.resume(returning: state)
}
}
}
}
/// Errors specific to Sign in with Apple token revocation.
enum SignInWithAppleRevocationError: Error, LocalizedError {
case invalidResponse
case revocationFailed(statusCode: Int)
var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid response from Apple's revocation endpoint."
case .revocationFailed(let statusCode):
return "Token revocation failed with status code \(statusCode)."
}
}
}