Prepare for the PgBouncer and IPv4 deprecations on 26th January 2024

Home

Build a User Management App with Swift and SwiftUI

This tutorial demonstrates how to build a basic user management app. The app authenticates and identifies the user, stores their profile information in the database, and allows the user to log in, update their profile details, and upload a profile photo. The app uses:

  • Supabase Database - a Postgres database for storing your user data and Row Level Security so data is protected and users can only access their own information.
  • Supabase Auth - users log in through magic links sent to their email (without having to set up passwords).
  • Supabase Storage - users can upload a profile photo.

Supabase User Management example

Project setup

Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase and then creating a "schema" inside the database.

Create a project

  1. Create a new project in the Supabase Dashboard.
  2. Enter your project details.
  3. Wait for the new database to launch.

Set up the database schema

Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor, or you can just copy/paste the SQL from below and run it yourself.

  1. Go to the SQL Editor page in the Dashboard.
  2. Click User Management Starter.
  3. Click Run.

_10
supabase link --project-ref <project-id>
_10
# You can get <project-id> from your project's dashboard URL: https://supabase.com/dashboard/project/<project-id>
_10
supabase db pull

Get the API Keys

Now that you've created some database tables, you are ready to insert data using the auto-generated API. We just need to get the Project URL and anon key from the API settings.

  1. Go to the API Settings page in the Dashboard.
  2. Find your Project URL, anon, and service_role keys on this page.

Building the app

Let's start building the SwiftUI app from scratch.

Create a SwiftUI app in Xcode

Open Xcode and create a new SwiftUI project.

Add the supabase-swift dependency.

Add the https://github.com/supabase-community/supabase-swift package to your app. For instructions, see the Apple tutorial on adding package dependencies.

Create a helper file to initialize the Supabase client. You need the API URL and the anon key that you copied earlier. These variables will be exposed on the application, and that's completely fine since you have Row Level Security enabled on your database.

Supabase.swift

_10
import Supabase
_10
_10
let supabase = SupabaseClient(
_10
supabaseURL: URL(string: "YOUR_SUPABASE_URL")!,
_10
supabaseKey: "YOUR_SUPABASE_ANON_KEY"
_10
)

Set up a login view

Set up a SwiftUI view to manage logins and sign ups. Users should be able to sign in using a magic link.

AuthView.swift

_66
import SwiftUI
_66
import Supabase
_66
_66
struct AuthView: View {
_66
@State var email = ""
_66
@State var isLoading = false
_66
@State var result: Result<Void, Error>?
_66
_66
var body: some View {
_66
Form {
_66
Section {
_66
TextField("Email", text: $email)
_66
.textContentType(.emailAddress)
_66
.textInputAutocapitalization(.never)
_66
.autocorrectionDisabled()
_66
}
_66
_66
Section {
_66
Button("Sign in") {
_66
signInButtonTapped()
_66
}
_66
_66
if isLoading {
_66
ProgressView()
_66
}
_66
}
_66
_66
if let result {
_66
Section {
_66
switch result {
_66
case .success:
_66
Text("Check your inbox.")
_66
case .failure(let error):
_66
Text(error.localizedDescription).foregroundStyle(.red)
_66
}
_66
}
_66
}
_66
}
_66
.onOpenURL(perform: { url in
_66
Task {
_66
do {
_66
try await supabase.auth.session(from: url)
_66
} catch {
_66
self.result = .failure(error)
_66
}
_66
}
_66
})
_66
}
_66
_66
func signInButtonTapped() {
_66
Task {
_66
isLoading = true
_66
defer { isLoading = false }
_66
_66
do {
_66
try await supabase.auth.signInWithOTP(
_66
email: email,
_66
redirectTo: URL(string: "io.supabase.user-management://login-callback")
_66
)
_66
result = .success(())
_66
} catch {
_66
result = .failure(error)
_66
}
_66
}
_66
}
_66
}

Account view

After a user is signed in, you can allow them to edit their profile details and manage their account.

Create a new view for that called ProfileView.swift.

ProfileView.swift

_93
struct ProfileView: View {
_93
@State var username = ""
_93
@State var fullName = ""
_93
@State var website = ""
_93
_93
@State var isLoading = false
_93
_93
var body: some View {
_93
NavigationStack {
_93
Form {
_93
Section {
_93
TextField("Username", text: $username)
_93
.textContentType(.username)
_93
.textInputAutocapitalization(.never)
_93
TextField("Full name", text: $fullName)
_93
.textContentType(.name)
_93
TextField("Website", text: $website)
_93
.textContentType(.URL)
_93
.textInputAutocapitalization(.never)
_93
}
_93
_93
Section {
_93
Button("Update profile") {
_93
updateProfileButtonTapped()
_93
}
_93
.bold()
_93
_93
if isLoading {
_93
ProgressView()
_93
}
_93
}
_93
}
_93
.navigationTitle("Profile")
_93
.toolbar(content: {
_93
ToolbarItem(placement: .topBarLeading){
_93
Button("Sign out", role: .destructive) {
_93
Task {
_93
try? await supabase.auth.signOut()
_93
}
_93
}
_93
}
_93
})
_93
}
_93
.task {
_93
await getInitialProfile()
_93
}
_93
}
_93
_93
func getInitialProfile() async {
_93
do {
_93
let currentUser = try await supabase.auth.session.user
_93
_93
let profile: Profile = try await supabase.database
_93
.from("profiles")
_93
.select()
_93
.eq("id", value: currentUser.id)
_93
.single()
_93
.execute()
_93
.value
_93
_93
self.username = profile.username ?? ""
_93
self.fullName = profile.fullName ?? ""
_93
self.website = profile.website ?? ""
_93
_93
} catch {
_93
debugPrint(error)
_93
}
_93
}
_93
_93
func updateProfileButtonTapped() {
_93
Task {
_93
isLoading = true
_93
defer { isLoading = false }
_93
do {
_93
let currentUser = try await supabase.auth.session.user
_93
_93
try await supabase.database
_93
.from("profiles")
_93
.update(
_93
UpdateProfileParams(
_93
username: username,
_93
fullName: fullName,
_93
website: website
_93
)
_93
)
_93
.eq("id", value: currentUser.id)
_93
.execute()
_93
} catch {
_93
debugPrint(error)
_93
}
_93
}
_93
}
_93
}

Models

In ProfileView.swift, you used 2 model types for deserializing the response and serializing the request to Supabase. Add those in a new Models.swift file.

Models.swift

_23
struct Profile: Decodable {
_23
let username: String?
_23
let fullName: String?
_23
let website: String?
_23
_23
enum CodingKeys: String, CodingKey {
_23
case username
_23
case fullName = "full_name"
_23
case website
_23
}
_23
}
_23
_23
struct UpdateProfileParams: Encodable {
_23
let username: String
_23
let fullName: String
_23
let website: String
_23
_23
enum CodingKeys: String, CodingKey {
_23
case username
_23
case fullName = "full_name"
_23
case website
_23
}
_23
}

Launch!

Now that you've created all the views, add an entry point for the application. This will verify if the user has a valid session and route them to the authenticated or non-authenticated state.

Add a new AppView.swift file.

AppView.swift

_20
struct AppView: View {
_20
@State var isAuthenticated = false
_20
_20
var body: some View {
_20
Group {
_20
if isAuthenticated {
_20
ProfileView()
_20
} else {
_20
AuthView()
_20
}
_20
}
_20
.task {
_20
for await state in await supabase.auth.authStateChanges {
_20
if [.initialSession, .signedIn, .signedOut].contains(state.event) {
_20
isAuthenticated = state.session != nil
_20
}
_20
}
_20
}
_20
}
_20
}

Update the entry point to the newly created AppView. Run in Xcode to launch your application in the simulator.

Bonus: Profile photos

Every Supabase project is configured with Storage for managing large files like photos and videos.

Add PhotosPicker

Let's add support for the user to pick an image from the library and upload it. Start by creating a new type to hold the picked avatar image:

AvatarImage.swift

_31
import SwiftUI
_31
_31
struct AvatarImage: Transferable, Equatable {
_31
let image: Image
_31
let data: Data
_31
_31
static var transferRepresentation: some TransferRepresentation {
_31
DataRepresentation(importedContentType: .image) { data in
_31
guard let image = AvatarImage(data: data) else {
_31
throw TransferError.importFailed
_31
}
_31
_31
return image
_31
}
_31
}
_31
}
_31
_31
extension AvatarImage {
_31
init?(data: Data) {
_31
guard let uiImage = UIImage(data: data) else {
_31
return nil
_31
}
_31
_31
let image = Image(uiImage: uiImage)
_31
self.init(image: image, data: data)
_31
}
_31
}
_31
_31
enum TransferError: Error {
_31
case importFailed
_31
}

Add PhotosPicker to profile page

ProfileView.swift

_161
struct ProfileView: View {
_161
@State var username = ""
_161
@State var fullName = ""
_161
@State var website = ""
_161
_161
@State var isLoading = false
_161
_161
+ @State var imageSelection: PhotosPickerItem?
_161
+ @State var avatarImage: AvatarImage?
_161
_161
var body: some View {
_161
NavigationStack {
_161
Form {
_161
+ Section {
_161
+ HStack {
_161
+ Group {
_161
+ if let avatarImage {
_161
+ avatarImage.image.resizable()
_161
+ } else {
_161
+ Color.clear
_161
+ }
_161
+ }
_161
+ .scaledToFit()
_161
+ .frame(width: 80, height: 80)
_161
+
_161
+ Spacer()
_161
+
_161
+ PhotosPicker(selection: $imageSelection, matching: .images) {
_161
+ Image(systemName: "pencil.circle.fill")
_161
+ .symbolRenderingMode(.multicolor)
_161
+ .font(.system(size: 30))
_161
+ .foregroundColor(.accentColor)
_161
+ }
_161
+ }
_161
+ }
_161
_161
Section {
_161
TextField("Username", text: $username)
_161
.textContentType(.username)
_161
.textInputAutocapitalization(.never)
_161
TextField("Full name", text: $fullName)
_161
.textContentType(.name)
_161
TextField("Website", text: $website)
_161
.textContentType(.URL)
_161
.textInputAutocapitalization(.never)
_161
}
_161
_161
Section {
_161
Button("Update profile") {
_161
updateProfileButtonTapped()
_161
}
_161
.bold()
_161
_161
if isLoading {
_161
ProgressView()
_161
}
_161
}
_161
}
_161
.navigationTitle("Profile")
_161
.toolbar(content: {
_161
ToolbarItem {
_161
Button("Sign out", role: .destructive) {
_161
Task {
_161
try? await supabase.auth.signOut()
_161
}
_161
}
_161
}
_161
})
_161
+ .onChange(of: imageSelection) { _, newValue in
_161
+ guard let newValue else { return }
_161
+ loadTransferable(from: newValue)
_161
+ }
_161
}
_161
.task {
_161
await getInitialProfile()
_161
}
_161
}
_161
_161
func getInitialProfile() async {
_161
do {
_161
let currentUser = try await supabase.auth.session.user
_161
_161
let profile: Profile = try await supabase.database
_161
.from("profiles")
_161
.select()
_161
.eq("id", value: currentUser.id)
_161
.single()
_161
.execute()
_161
.value
_161
_161
username = profile.username ?? ""
_161
fullName = profile.fullName ?? ""
_161
website = profile.website ?? ""
_161
_161
+ if let avatarURL = profile.avatarURL, !avatarURL.isEmpty {
_161
+ try await downloadImage(path: avatarURL)
_161
+ }
_161
_161
} catch {
_161
debugPrint(error)
_161
}
_161
}
_161
_161
func updateProfileButtonTapped() {
_161
Task {
_161
isLoading = true
_161
defer { isLoading = false }
_161
do {
_161
+ let imageURL = try await uploadImage()
_161
_161
let currentUser = try await supabase.auth.session.user
_161
_161
let updatedProfile = Profile(
_161
username: username,
_161
fullName: fullName,
_161
website: website,
_161
+ avatarURL: imageURL
_161
)
_161
_161
try await supabase.database
_161
.from("profiles")
_161
.update(updatedProfile)
_161
.eq("id", value: currentUser.id)
_161
.execute()
_161
} catch {
_161
debugPrint(error)
_161
}
_161
}
_161
}
_161
_161
+ private func loadTransferable(from imageSelection: PhotosPickerItem) {
_161
+ Task {
_161
+ do {
_161
+ avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self)
_161
+ } catch {
_161
+ debugPrint(error)
_161
+ }
_161
+ }
_161
+ }
_161
+
_161
+ private func downloadImage(path: String) async throws {
_161
+ let data = try await supabase.storage.from("avatars").download(path: path)
_161
+ avatarImage = AvatarImage(data: data)
_161
+ }
_161
+
_161
+ private func uploadImage() async throws -> String? {
_161
+ guard let data = avatarImage?.data else { return nil }
_161
+
_161
+ let filePath = "\(UUID().uuidString).jpeg"
_161
+
_161
+ try await supabase.storage
_161
+ .from("avatars")
_161
+ .upload(
_161
+ path: filePath,
_161
+ file: data,
_161
+ options: FileOptions(contentType: "image/jpeg")
_161
+ )
_161
+
_161
+ return filePath
_161
+ }
_161
}

Finally, update your Models.

Models.swift

_13
struct Profile: Codable {
_13
let username: String?
_13
let fullName: String?
_13
let website: String?
_13
let avatarURL: String?
_13
_13
enum CodingKeys: String, CodingKey {
_13
case username
_13
case fullName = "full_name"
_13
case website
_13
case avatarURL = "avatar_url"
_13
}
_13
}

You no longer need the UpdateProfileParams struct, as you can now reuse the Profile struct for both request and response calls.

Storage management

If you upload additional profile photos, they'll accumulate in the avatars bucket because of their random names with only the latest being referenced from public.profiles and the older versions getting orphaned.

To automatically remove obsolete storage objects, extend the database triggers. Note that it is not sufficient to delete the objects from the storage.objects table because that would orphan and leak the actual storage objects in the S3 backend. Instead, invoke the storage API within Postgres via the http extension.

Enable the http extension for the extensions schema in the Dashboard. Then, define the following SQL functions in the SQL Editor to delete storage objects via the API:


_34
create or replace function delete_storage_object(bucket text, object text, out status int, out content text)
_34
returns record
_34
language 'plpgsql'
_34
security definer
_34
as $$
_34
declare
_34
project_url text := '<YOURPROJECTURL>';
_34
service_role_key text := '<YOURSERVICEROLEKEY>'; -- full access needed
_34
url text := project_url||'/storage/v1/object/'||bucket||'/'||object;
_34
begin
_34
select
_34
into status, content
_34
result.status::int, result.content::text
_34
FROM extensions.http((
_34
'DELETE',
_34
url,
_34
ARRAY[extensions.http_header('authorization','Bearer '||service_role_key)],
_34
NULL,
_34
NULL)::extensions.http_request) as result;
_34
end;
_34
$$;
_34
_34
create or replace function delete_avatar(avatar_url text, out status int, out content text)
_34
returns record
_34
language 'plpgsql'
_34
security definer
_34
as $$
_34
begin
_34
select
_34
into status, content
_34
result.status, result.content
_34
from public.delete_storage_object('avatars', avatar_url) as result;
_34
end;
_34
$$;

Next, add a trigger that removes any obsolete avatar whenever the profile is updated or deleted:


_32
create or replace function delete_old_avatar()
_32
returns trigger
_32
language 'plpgsql'
_32
security definer
_32
as $$
_32
declare
_32
status int;
_32
content text;
_32
avatar_name text;
_32
begin
_32
if coalesce(old.avatar_url, '') <> ''
_32
and (tg_op = 'DELETE' or (old.avatar_url <> coalesce(new.avatar_url, ''))) then
_32
-- extract avatar name
_32
avatar_name := old.avatar_url;
_32
select
_32
into status, content
_32
result.status, result.content
_32
from public.delete_avatar(avatar_name) as result;
_32
if status <> 200 then
_32
raise warning 'Could not delete avatar: % %', status, content;
_32
end if;
_32
end if;
_32
if tg_op = 'DELETE' then
_32
return old;
_32
end if;
_32
return new;
_32
end;
_32
$$;
_32
_32
create trigger before_profile_changes
_32
before update of avatar_url or delete on public.profiles
_32
for each row execute function public.delete_old_avatar();

Finally, delete the public.profile row before a user is deleted. If this step is omitted, you won't be able to delete users without first manually deleting their avatar image.


_14
create or replace function delete_old_profile()
_14
returns trigger
_14
language 'plpgsql'
_14
security definer
_14
as $$
_14
begin
_14
delete from public.profiles where id = old.id;
_14
return old;
_14
end;
_14
$$;
_14
_14
create trigger before_delete_user
_14
before delete on auth.users
_14
for each row execute function public.delete_old_profile();

At this stage you have a fully functional application!