Skip to content

Commit 042acc0

Browse files
authored
feat: add AdminAPI and deleteUser method (#224)
* feat: add AdminAPI and deleteUser method * Add delete account example and fix error decoding * docs
1 parent b0e5594 commit 042acc0

File tree

18 files changed

+372
-29
lines changed

18 files changed

+372
-29
lines changed

Examples/UserManagement/AuthView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import Supabase
99
import SwiftUI
1010

11+
@MainActor
1112
struct AuthView: View {
1213
@State var email = ""
1314
@State var isLoading = false

Examples/UserManagement/ProfileView.swift

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import PhotosUI
99
import Supabase
1010
import SwiftUI
1111

12+
@MainActor
1213
struct ProfileView: View {
1314
@State var username = ""
1415
@State var fullName = ""
@@ -69,6 +70,10 @@ struct ProfileView: View {
6970
if isLoading {
7071
ProgressView()
7172
}
73+
74+
Button("Delete account", role: .destructive) {
75+
deleteAccountButtonTapped()
76+
}
7277
}
7378
}
7479
.onMac { $0.padding() }
@@ -82,7 +87,7 @@ struct ProfileView: View {
8287
}
8388
}
8489
})
85-
.onChange(of: imageSelection) { newValue in
90+
.onChange(of: imageSelection) { _, newValue in
8691
guard let newValue else { return }
8792
loadTransferable(from: newValue)
8893
}
@@ -174,6 +179,20 @@ struct ProfileView: View {
174179

175180
return filePath
176181
}
182+
183+
private func deleteAccountButtonTapped() {
184+
Task {
185+
do {
186+
let currentUserId = try await supabase.auth.session.user.id
187+
try await supabase.auth.admin.deleteUser(
188+
id: currentUserId.uuidString,
189+
shouldSoftDelete: true
190+
)
191+
} catch {
192+
debugPrint(error)
193+
}
194+
}
195+
}
177196
}
178197

179198
#Preview {

Examples/UserManagement/Supabase.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import OSLog
1010
import Supabase
1111

1212
let supabase = SupabaseClient(
13-
supabaseURL: URL(string: "https://PROJECT_ID.supabase.co")!,
14-
supabaseKey: "YOUR_SUPABASE_ANON_KEY",
13+
supabaseURL: URL(string: "http://127.0.0.1:54321")!,
14+
supabaseKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU",
1515
options: .init(
1616
global: .init(logger: AppLogger())
1717
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Supabase
2+
.branches
3+
.temp
4+
.env
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# A string used to distinguish different Supabase projects on the same host. Defaults to the
2+
# working directory name when running `supabase init`.
3+
project_id = "UserManagement"
4+
5+
[api]
6+
enabled = true
7+
# Port to use for the API URL.
8+
port = 54321
9+
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
10+
# endpoints. public and storage are always included.
11+
schemas = ["public", "storage", "graphql_public"]
12+
# Extra schemas to add to the search_path of every request. public is always included.
13+
extra_search_path = ["public", "extensions"]
14+
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
15+
# for accidental or malicious requests.
16+
max_rows = 1000
17+
18+
[db]
19+
# Port to use for the local database URL.
20+
port = 54322
21+
# Port used by db diff command to initialize the shadow database.
22+
shadow_port = 54320
23+
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
24+
# server_version;` on the remote database to check.
25+
major_version = 15
26+
27+
[db.pooler]
28+
enabled = false
29+
# Port to use for the local connection pooler.
30+
port = 54329
31+
# Specifies when a server connection can be reused by other clients.
32+
# Configure one of the supported pooler modes: `transaction`, `session`.
33+
pool_mode = "transaction"
34+
# How many server connections to allow per user/database pair.
35+
default_pool_size = 20
36+
# Maximum number of client connections allowed.
37+
max_client_conn = 100
38+
39+
[realtime]
40+
enabled = true
41+
# Bind realtime via either IPv4 or IPv6. (default: IPv6)
42+
# ip_version = "IPv6"
43+
# The maximum length in bytes of HTTP request headers. (default: 4096)
44+
# max_header_length = 4096
45+
46+
[studio]
47+
enabled = true
48+
# Port to use for Supabase Studio.
49+
port = 54323
50+
# External URL of the API server that frontend connects to.
51+
api_url = "http://127.0.0.1"
52+
53+
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
54+
# are monitored, and you can view the emails that would have been sent from the web interface.
55+
[inbucket]
56+
enabled = true
57+
# Port to use for the email testing server web interface.
58+
port = 54324
59+
# Uncomment to expose additional ports for testing user applications that send emails.
60+
# smtp_port = 54325
61+
# pop3_port = 54326
62+
63+
[storage]
64+
enabled = true
65+
# The maximum file size allowed (e.g. "5MB", "500KB").
66+
file_size_limit = "50MiB"
67+
68+
[auth]
69+
enabled = true
70+
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
71+
# in emails.
72+
site_url = "http://127.0.0.1:3000"
73+
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
74+
additional_redirect_urls = ["https://127.0.0.1:3000", "io.supabase.user-management://*"]
75+
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
76+
jwt_expiry = 3600
77+
# If disabled, the refresh token will never expire.
78+
enable_refresh_token_rotation = true
79+
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
80+
# Requires enable_refresh_token_rotation = true.
81+
refresh_token_reuse_interval = 10
82+
# Allow/disallow new user signups to your project.
83+
enable_signup = true
84+
# Allow/disallow testing manual linking of accounts
85+
enable_manual_linking = false
86+
87+
[auth.email]
88+
# Allow/disallow new user signups via email to your project.
89+
enable_signup = true
90+
# If enabled, a user will be required to confirm any email change on both the old, and new email
91+
# addresses. If disabled, only the new email is required to confirm.
92+
double_confirm_changes = true
93+
# If enabled, users need to confirm their email address before signing in.
94+
enable_confirmations = false
95+
96+
# Uncomment to customize email template
97+
# [auth.email.template.invite]
98+
# subject = "You have been invited"
99+
# content_path = "./supabase/templates/invite.html"
100+
101+
[auth.sms]
102+
# Allow/disallow new user signups via SMS to your project.
103+
enable_signup = true
104+
# If enabled, users need to confirm their phone number before signing in.
105+
enable_confirmations = false
106+
# Template for sending OTP to users
107+
template = "Your code is {{ .Code }} ."
108+
109+
# Use pre-defined map of phone number to OTP for testing.
110+
[auth.sms.test_otp]
111+
# 4152127777 = "123456"
112+
113+
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
114+
[auth.hook.custom_access_token]
115+
# enabled = true
116+
# uri = "pg-functions://<database>/<schema>/<hook_name>"
117+
118+
119+
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
120+
[auth.sms.twilio]
121+
enabled = false
122+
account_sid = ""
123+
message_service_sid = ""
124+
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
125+
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
126+
127+
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
128+
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
129+
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
130+
[auth.external.apple]
131+
enabled = false
132+
client_id = ""
133+
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
134+
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
135+
# Overrides the default auth redirectUrl.
136+
redirect_uri = ""
137+
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
138+
# or any other third-party OIDC providers.
139+
url = ""
140+
141+
[analytics]
142+
enabled = false
143+
port = 54327
144+
vector_port = 54328
145+
# Configure one of the supported backends: `postgres`, `bigquery`.
146+
backend = "postgres"
147+
148+
# Experimental features may be deprecated any time
149+
[experimental]
150+
# Configures Postgres storage engine to use OrioleDB (S3)
151+
orioledb_version = ""
152+
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
153+
s3_host = "env(S3_HOST)"
154+
# Configures S3 bucket region, eg. us-east-1
155+
s3_region = "env(S3_REGION)"
156+
# Configures AWS_ACCESS_KEY_ID for S3 bucket
157+
s3_access_key = "env(S3_ACCESS_KEY)"
158+
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
159+
s3_secret_key = "env(S3_SECRET_KEY)"
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
-- Create a table for public profiles
2+
create table profiles(
3+
id uuid references auth.users not null primary key,
4+
updated_at timestamp with time zone,
5+
username text unique,
6+
full_name text,
7+
avatar_url text,
8+
website text,
9+
constraint username_length check (char_length(username) >= 3)
10+
);
11+
12+
-- Set up Row Level Security (RLS)
13+
-- See https://supabase.com/docs/guides/auth/row-level-security for more details.
14+
alter table profiles enable row level security;
15+
16+
create policy "Public profiles are viewable by everyone." on profiles
17+
for select
18+
using (true);
19+
20+
create policy "Users can insert their own profile." on profiles
21+
for insert
22+
with check (auth.uid() = id);
23+
24+
create policy "Users can update own profile." on profiles
25+
for update
26+
using (auth.uid() = id);
27+
28+
-- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth.
29+
-- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details.
30+
create function public.handle_new_user()
31+
returns trigger
32+
as $$
33+
begin
34+
insert into public.profiles(id, full_name, avatar_url)
35+
values(new.id, new.raw_user_meta_data ->> 'full_name', new.raw_user_meta_data ->> 'avatar_url');
36+
return new;
37+
end;
38+
$$
39+
language plpgsql
40+
security definer;
41+
42+
create trigger on_auth_user_created
43+
after insert on auth.users for each row
44+
execute procedure public.handle_new_user();
45+
46+
-- Set up Storage!
47+
insert into storage.buckets(id, name)
48+
values ('avatars', 'avatars');
49+
50+
-- Set up access controls for storage.
51+
-- See https://supabase.com/docs/guides/storage/security/access-control#policy-examples for more details.
52+
create policy "Avatar images are publicly accessible." on storage.objects
53+
for select
54+
using (bucket_id = 'avatars');
55+
56+
create policy "Anyone can upload an avatar." on storage.objects
57+
for insert
58+
with check (bucket_id = 'avatars');
59+
60+
create policy "Anyone can update their own avatar." on storage.objects
61+
for update
62+
using (auth.uid() = owner)
63+
with check (bucket_id = 'avatars');
64+

Examples/UserManagement/supabase/seed.sql

Whitespace-only changes.

Sources/Auth/AuthAdmin.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//
2+
// AuthAdmin.swift
3+
//
4+
//
5+
// Created by Guilherme Souza on 25/01/24.
6+
//
7+
8+
import Foundation
9+
@_spi(Internal) import _Helpers
10+
11+
public actor AuthAdmin {
12+
private var configuration: AuthClient.Configuration {
13+
Dependencies.current.value!.configuration
14+
}
15+
16+
private var api: APIClient {
17+
Dependencies.current.value!.api
18+
}
19+
20+
/// Delete a user. Requires `service_role` key.
21+
/// - Parameter id: The id of the user you want to delete.
22+
/// - Parameter shouldSoftDelete: If true, then the user will be soft-deleted (setting
23+
/// `deleted_at` to the current timestamp and disabling their account while preserving their data)
24+
/// from the auth schema.
25+
///
26+
/// - Warning: Never expose your `service_role` key on the client.
27+
public func deleteUser(id: String, shouldSoftDelete: Bool = false) async throws {
28+
_ = try await api.execute(
29+
Request(
30+
path: "/admin/users/\(id)",
31+
method: .delete,
32+
body: configuration.encoder.encode(DeleteUserRequest(shouldSoftDelete: shouldSoftDelete))
33+
)
34+
)
35+
}
36+
}

Sources/Auth/AuthClient.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ public actor AuthClient {
9595
/// Namespace for accessing multi-factor authentication API.
9696
public let mfa: AuthMFA
9797

98+
/// Namespace for the GoTrue admin methods.
99+
/// - Warning: This methods requires `service_role` key, be careful to never expose `service_role`
100+
/// key in the client.
101+
public let admin: AuthAdmin
102+
98103
/// Initializes a AuthClient with optional parameters.
99104
///
100105
/// - Parameters:
@@ -162,6 +167,7 @@ public actor AuthClient {
162167
logger: SupabaseLogger?
163168
) {
164169
mfa = AuthMFA()
170+
admin = AuthAdmin()
165171

166172
Dependencies.current.setValue(
167173
Dependencies(

Sources/Auth/Internal/APIClient.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,21 @@ extension APIClient {
1919
let response = try await http.fetch(request, baseURL: configuration.url)
2020

2121
guard (200 ..< 300).contains(response.statusCode) else {
22-
let apiError = try configuration.decoder.decode(
22+
if let apiError = try? configuration.decoder.decode(
2323
AuthError.APIError.self,
2424
from: response.data
25+
) {
26+
throw AuthError.api(apiError)
27+
}
28+
29+
/// There are some GoTrue endpoints that can return a `PostgrestError`, for example the
30+
/// ``AuthAdmin/deleteUser(id:shouldSoftDelete:)`` that could return an error in case the
31+
/// user is referenced by other schemas.
32+
let postgrestError = try configuration.decoder.decode(
33+
PostgrestError.self,
34+
from: response.data
2535
)
26-
throw AuthError.api(apiError)
36+
throw postgrestError
2737
}
2838

2939
return response

0 commit comments

Comments
 (0)