mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 10:55:06 +03:00
## What kind of change does this PR introduce? Docs update. Aligns documentation and style guides with the **Sign in / Sign out / Sign up** platform standard. Closes DOCS-1328. Related to [#49874](https://github.com/supabase/supabase/pull/49874). ## What is the current behavior? Docs style guides prefer _login_ / _log in_. Guide prose uses mixed login and sign in wording. ## What is the new behavior? - [WORD_LIST.md](apps/docs/WORD_LIST.md) and [copywriting.mdx](apps/design-system/content/docs/copywriting.mdx) document the sign in standard - Design-system auth examples updated - Guide prose and API reference spec descriptions updated ### Terminology **Standard:** Use _sign in_, _sign out_, and _sign up_ as verbs. Use _sign-in_, _sign-out_, and _sign-up_ as nouns and adjectives. Match Studio UI labels (**Sign in**, **Sign out**, **Sign up**). **Preserved intentionally:** | Category | Keep as-is | Example | | -------- | ---------- | ------- | | Feature name | social login | `/social-login`, `features.mdx` heading, OAuth provider section | | URL slugs | `login` in paths | `/phone-login`, `/login-flows`, `choosing-login-flow` | | CLI | `supabase login` / `supabase logout` | Reference ids `supabase-login` / `supabase-logout`; executable commands unchanged | | SDK methods | `logout()` | Kotlin/Swift method names in API reference titles and examples | | Third-party UI | Provider product labels | Facebook Login, Kakao Login, portal **Login** buttons | | Postgres | Database terminology | login privileges, login credentials, login via role | | Audit/logging | Log prose | "Generates the following **log** in the Postgres Logs" | | Code and routes | Paths and filenames | `app/login/`, `Login.tsx`, `demos/android-login` | | External URLs | Third-party login pages | `dash.cloudflare.com/login`, `console.neon.tech/login`, `vercel.com/login` | | API identifiers | Event and field names | Audit actions `login`/`logout`, `should_logout_user` | ## To test - Run `pnpm lint:mdx` in `apps/docs` - Spot-check `features.mdx`, `social-login.mdx`, and a provider guide (e.g. Facebook, Kakao) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Standardized authentication terminology across guides, reference material, CLI documentation, and copywriting guidance using “sign in,” “sign out,” and “sign up.” * Updated authentication instructions, headings, link text, examples, and SSO guidance for clearer, more consistent wording. * Corrected related grammar, spelling, hyphenation, and documentation links while preserving established product names and implementation commands. * **Style** * Refined code examples with consistent import ordering and spacing. * **Examples** * Updated authentication button and menu labels to “Sign in” and “Sign out.” <!-- end of auto-generated comment: release notes by coderabbit.ai -->
565 lines
13 KiB
Plaintext
565 lines
13 KiB
Plaintext
---
|
|
title: 'Build a User Management App with Swift and SwiftUI'
|
|
description: 'Learn how to use Supabase in your SwiftUI App.'
|
|
---
|
|
|
|
<$Partial path="quickstart_intro.mdx" />
|
|
|
|

|
|
|
|
<Admonition type="note">
|
|
|
|
If you get stuck while working through this guide, you can find the [full example on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/swift-user-management).
|
|
|
|
</Admonition>
|
|
|
|
<$Partial path="project_setup.mdx" variables={{ "framework": "swift", "tab": "mobiles" }} />
|
|
|
|
## Building the app
|
|
|
|
Build the SwiftUI app from scratch.
|
|
|
|
### Create a SwiftUI app in Xcode
|
|
|
|
Open Xcode and create a new SwiftUI project.
|
|
|
|
Add the [supabase-swift](https://github.com/supabase/supabase-swift) dependency.
|
|
|
|
Add the `https://github.com/supabase/supabase-swift` package to your app. For instructions, see the [Apple tutorial on adding package dependencies](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app).
|
|
|
|
Create a helper file to initialize the Supabase client.
|
|
You need the API URL and the key that you copied [earlier](#get-api-details).
|
|
These variables will be exposed on the application, and that's completely fine since you have
|
|
[Row Level Security](/docs/guides/auth#row-level-security) enabled on your database.
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=Supabase.swift
|
|
import Foundation
|
|
import Supabase
|
|
|
|
let supabase = SupabaseClient(
|
|
supabaseURL: URL(string: "YOUR_SUPABASE_URL")!,
|
|
supabaseKey: "YOUR_SUPABASE_PUBLISHABLE_KEY"
|
|
)
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Set up a sign-in view
|
|
|
|
Set up a SwiftUI view to manage sign-ins and sign-ups.
|
|
Users should be able to sign in using a magic link.
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=AuthView.swift
|
|
import SwiftUI
|
|
import Supabase
|
|
|
|
struct AuthView: View {
|
|
@State var email = ""
|
|
@State var isLoading = false
|
|
@State var result: Result<Void, Error>?
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section {
|
|
TextField("Email", text: $email)
|
|
.textContentType(.emailAddress)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
}
|
|
|
|
Section {
|
|
Button("Sign in") {
|
|
signInButtonTapped()
|
|
}
|
|
|
|
if isLoading {
|
|
ProgressView()
|
|
}
|
|
}
|
|
|
|
if let result {
|
|
Section {
|
|
switch result {
|
|
case .success:
|
|
Text("Check your inbox.")
|
|
case .failure(let error):
|
|
Text(error.localizedDescription).foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onOpenURL(perform: { url in
|
|
Task {
|
|
do {
|
|
try await supabase.auth.session(from: url)
|
|
} catch {
|
|
self.result = .failure(error)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func signInButtonTapped() {
|
|
Task {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
try await supabase.auth.signInWithOTP(
|
|
email: email,
|
|
redirectTo: URL(string: "io.supabase.user-management://login-callback")
|
|
)
|
|
result = .success(())
|
|
} catch {
|
|
result = .failure(error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
<Admonition type="note">
|
|
|
|
The example uses a custom `redirectTo` URL. For this to work, add a custom redirect URL to Supabase and a custom URL scheme to your SwiftUI application. Follow the guide on [implementing deep link handling](/docs/guides/auth/native-mobile-deep-linking?platform=swift).
|
|
|
|
</Admonition>
|
|
|
|
### 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`.
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=ProfileView.swift
|
|
import SwiftUI
|
|
|
|
struct ProfileView: View {
|
|
@State var username = ""
|
|
@State var fullName = ""
|
|
@State var website = ""
|
|
|
|
@State var isLoading = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section {
|
|
TextField("Username", text: $username)
|
|
.textContentType(.username)
|
|
.textInputAutocapitalization(.never)
|
|
TextField("Full name", text: $fullName)
|
|
.textContentType(.name)
|
|
TextField("Website", text: $website)
|
|
.textContentType(.URL)
|
|
.textInputAutocapitalization(.never)
|
|
}
|
|
|
|
Section {
|
|
Button("Update profile") {
|
|
updateProfileButtonTapped()
|
|
}
|
|
.bold()
|
|
|
|
if isLoading {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Profile")
|
|
.toolbar(content: {
|
|
ToolbarItem(placement: .topBarLeading){
|
|
Button("Sign out", role: .destructive) {
|
|
Task {
|
|
try? await supabase.auth.signOut()
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
.task {
|
|
await getInitialProfile()
|
|
}
|
|
}
|
|
|
|
func getInitialProfile() async {
|
|
do {
|
|
let currentUser = try await supabase.auth.session.user
|
|
|
|
let profile: Profile =
|
|
try await supabase
|
|
.from("profiles")
|
|
.select()
|
|
.eq("id", value: currentUser.id)
|
|
.single()
|
|
.execute()
|
|
.value
|
|
|
|
self.username = profile.username ?? ""
|
|
self.fullName = profile.fullName ?? ""
|
|
self.website = profile.website ?? ""
|
|
|
|
} catch {
|
|
debugPrint(error)
|
|
}
|
|
}
|
|
|
|
func updateProfileButtonTapped() {
|
|
Task {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
do {
|
|
let currentUser = try await supabase.auth.session.user
|
|
|
|
try await supabase
|
|
.from("profiles")
|
|
.update(
|
|
UpdateProfileParams(
|
|
username: username,
|
|
fullName: fullName,
|
|
website: website
|
|
)
|
|
)
|
|
.eq("id", value: currentUser.id)
|
|
.execute()
|
|
} catch {
|
|
debugPrint(error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### 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.
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=Models.swift
|
|
struct Profile: Decodable {
|
|
let username: String?
|
|
let fullName: String?
|
|
let website: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case username
|
|
case fullName = "full_name"
|
|
case website
|
|
}
|
|
}
|
|
|
|
struct UpdateProfileParams: Encodable {
|
|
let username: String
|
|
let fullName: String
|
|
let website: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case username
|
|
case fullName = "full_name"
|
|
case website
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
## Profile photos
|
|
|
|
Next, add a way for users to upload a profile photo. Supabase configures every project with [Storage](/docs/guides/storage) for managing large files like photos and videos.
|
|
|
|
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
|
|
|
### Add `PhotosPicker`
|
|
|
|
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:
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=AvatarImage.swift
|
|
import SwiftUI
|
|
|
|
struct AvatarImage: Transferable, Equatable {
|
|
let image: Image
|
|
let data: Data
|
|
|
|
static var transferRepresentation: some TransferRepresentation {
|
|
DataRepresentation(importedContentType: .image) { data in
|
|
guard let image = AvatarImage(data: data) else {
|
|
throw TransferError.importFailed
|
|
}
|
|
|
|
return image
|
|
}
|
|
}
|
|
}
|
|
|
|
extension AvatarImage {
|
|
init?(data: Data) {
|
|
guard let uiImage = UIImage(data: data) else {
|
|
return nil
|
|
}
|
|
|
|
let image = Image(uiImage: uiImage)
|
|
self.init(image: image, data: data)
|
|
}
|
|
}
|
|
|
|
enum TransferError: Error {
|
|
case importFailed
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
|
|
|
#### Add `PhotosPicker` to profile page
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=ProfileView.swift
|
|
import PhotosUI
|
|
import Storage
|
|
import Supabase
|
|
import SwiftUI
|
|
|
|
struct ProfileView: View {
|
|
@State var username = ""
|
|
@State var fullName = ""
|
|
@State var website = ""
|
|
|
|
@State var isLoading = false
|
|
|
|
@State var imageSelection: PhotosPickerItem?
|
|
@State var avatarImage: AvatarImage?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section {
|
|
HStack {
|
|
Group {
|
|
if let avatarImage {
|
|
avatarImage.image.resizable()
|
|
} else {
|
|
Color.clear
|
|
}
|
|
}
|
|
.scaledToFit()
|
|
.frame(width: 80, height: 80)
|
|
|
|
Spacer()
|
|
|
|
PhotosPicker(selection: $imageSelection, matching: .images) {
|
|
Image(systemName: "pencil.circle.fill")
|
|
.symbolRenderingMode(.multicolor)
|
|
.font(.system(size: 30))
|
|
.foregroundColor(.accentColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
Section {
|
|
TextField("Username", text: $username)
|
|
.textContentType(.username)
|
|
.textInputAutocapitalization(.never)
|
|
TextField("Full name", text: $fullName)
|
|
.textContentType(.name)
|
|
TextField("Website", text: $website)
|
|
.textContentType(.URL)
|
|
.textInputAutocapitalization(.never)
|
|
}
|
|
|
|
Section {
|
|
Button("Update profile") {
|
|
updateProfileButtonTapped()
|
|
}
|
|
.bold()
|
|
|
|
if isLoading {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Profile")
|
|
.toolbar(content: {
|
|
ToolbarItem {
|
|
Button("Sign out", role: .destructive) {
|
|
Task {
|
|
try? await supabase.auth.signOut()
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.onChange(of: imageSelection) { _, newValue in
|
|
guard let newValue else { return }
|
|
loadTransferable(from: newValue)
|
|
}
|
|
}
|
|
.task {
|
|
await getInitialProfile()
|
|
}
|
|
}
|
|
|
|
func getInitialProfile() async {
|
|
do {
|
|
let currentUser = try await supabase.auth.session.user
|
|
|
|
let profile: Profile =
|
|
try await supabase
|
|
.from("profiles")
|
|
.select()
|
|
.eq("id", value: currentUser.id)
|
|
.single()
|
|
.execute()
|
|
.value
|
|
|
|
username = profile.username ?? ""
|
|
fullName = profile.fullName ?? ""
|
|
website = profile.website ?? ""
|
|
|
|
if let avatarURL = profile.avatarURL, !avatarURL.isEmpty {
|
|
try await downloadImage(path: avatarURL)
|
|
}
|
|
|
|
} catch {
|
|
debugPrint(error)
|
|
}
|
|
}
|
|
|
|
func updateProfileButtonTapped() {
|
|
Task {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
do {
|
|
let imageURL = try await uploadImage()
|
|
|
|
let currentUser = try await supabase.auth.session.user
|
|
|
|
let updatedProfile = Profile(
|
|
username: username,
|
|
fullName: fullName,
|
|
website: website,
|
|
avatarURL: imageURL
|
|
)
|
|
|
|
try await supabase
|
|
.from("profiles")
|
|
.update(updatedProfile)
|
|
.eq("id", value: currentUser.id)
|
|
.execute()
|
|
} catch {
|
|
debugPrint(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadTransferable(from imageSelection: PhotosPickerItem) {
|
|
Task {
|
|
do {
|
|
avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self)
|
|
} catch {
|
|
debugPrint(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func downloadImage(path: String) async throws {
|
|
let data = try await supabase.storage.from("avatars").download(path: path)
|
|
avatarImage = AvatarImage(data: data)
|
|
}
|
|
|
|
private func uploadImage() async throws -> String? {
|
|
guard let data = avatarImage?.data else { return nil }
|
|
|
|
let filePath = "\(UUID().uuidString).jpeg"
|
|
|
|
try await supabase.storage
|
|
.from("avatars")
|
|
.upload(
|
|
filePath,
|
|
data: data,
|
|
options: FileOptions(contentType: "image/jpeg")
|
|
)
|
|
|
|
return filePath
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
Finally, update your Models.
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=Models.swift
|
|
struct Profile: Codable {
|
|
let username: String?
|
|
let fullName: String?
|
|
let website: String?
|
|
let avatarURL: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case username
|
|
case fullName = "full_name"
|
|
case website
|
|
case avatarURL = "avatar_url"
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
You no longer need the `UpdateProfileParams` struct, as you can now reuse the `Profile` struct for both request and response calls.
|
|
|
|
### Launch!
|
|
|
|
With all the views in place, add an entry point for the application.
|
|
|
|
Add a new `AppView.swift` file.
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=AppView.swift
|
|
import SwiftUI
|
|
|
|
struct AppView: View {
|
|
@State var isAuthenticated = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isAuthenticated {
|
|
ProfileView()
|
|
} else {
|
|
AuthView()
|
|
}
|
|
}
|
|
.task {
|
|
for await state in supabase.auth.authStateChanges {
|
|
if [.initialSession, .signedIn, .signedOut].contains(state.event) {
|
|
isAuthenticated = state.session != nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
Update the entry point to the newly created `AppView`. Run in Xcode to launch your application in the simulator.
|
|
|
|
At this stage you have a fully functional application!
|