Build a Visitor Management Mobile App

Build a full-stack mobile app with React Native, Spring Boot, and AWS.

Introduction

30 Second Summary

Every gated community has the same problem. A visitor arrives, the guard needs to verify their identity, figure out who they're visiting, and get approval from the homeowner. Most places still do this with clipboards and phone calls.

In this project, you will build a complete Subdivision Visitor Management System called GateGuard. Guards scan visitor IDs using their phone camera, select the homeowner being visited, and the homeowner receives a real-time push notification to accept or reject the visitor.

What You'll Build

A full-stack mobile application with on-device OCR, a secured REST API backed by enterprise authentication, and cloud deployment on AWS.

By the end of this project, you'll have:

  • A React Native mobile app with role-based screens for guards and homeowners, powered by on-device OCR using Google ML Kit for instant ID scanning.
  • A Spring Boot REST API secured by Keycloak with OAuth2 authentication, Liquibase database migrations, and deployment to AWS EC2.
  • Hands-on AWS infrastructure experience across EC2, RDS, S3, SES, VPC, IAM, and CloudWatch billing alarms.
  • Secret Mission: Add a visitor history analytics dashboard showing visit patterns, peak hours, and frequent visitors.

Are there any prerequisites?

This is a multi-session project designed for learners with Java development experience. You'll need Java Temurin 21, IntelliJ IDEA, PostgreSQL, Node.js v26.0.0, an AWS account, and an Android phone for testing.

Step 1 walks you through installing everything else you need, including Expo CLI, Docker Desktop, and Android Studio.

Before We Start

Before We Start...

Set Up the Mobile Development Environment

Before you can build the GateGuard mobile app, you need the right tools on your machine. React Native apps built with Expo require a specific set of CLI tools, a container runtime for backend services, and a physical device connection for testing.

In this step, you will install the Expo and EAS command-line tools, scaffold your React Native project, set up Docker Desktop for running Keycloak later, and configure your Android phone for USB debugging.

In this step, get ready to:
  • Install Expo CLI and EAS CLI, then create the GateGuard project.
  • Install Docker Desktop and verify it runs.
  • Install Android Studio and configure USB debugging on your phone.
Install Expo CLI and create the GateGuard project

Expo provides a managed workflow for building React Native apps. Since Expo SDK 46, the Expo CLI ships locally with each project. You do not need a global expo-cli install.

You will use npx to create the project and run Expo commands. You also need EAS CLI globally for building and submitting native binaries later.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Install EAS CLI globally by running this command:
npm install --global eas-cli

What is EAS CLI?

EAS CLI is a command-line tool from Expo that handles building native binaries, submitting to app stores, and managing over-the-air updates. You need it installed globally so you can run eas commands from any directory.

  • Create the GateGuard project by running this command:
npx create-expo-app@latest GateGuard

What does this command do?

This scaffolds a new React Native project using Expo SDK 56 with TypeScript enabled. It creates a GateGuard folder on your Desktop containing all the starter files.

The default template includes Expo Router, TypeScript configuration, and the local Expo CLI bundled with the expo package. You run Expo commands via npx expo from inside the project.

  • Move into the new project folder by running:
cd GateGuard
  • Verify the project was created by running:
ls

You should see files including app.json, package.json, and a node_modules folder.

  • Verify the Expo CLI works inside the project by running:
npx expo --version

You should see a version number printed, confirming the local Expo CLI is available.

Seeing a permission error during install?

If npm install --global fails with a permission error on macOS, prefix the command with sudo. On Windows, run PowerShell as Administrator.

If npx create-expo-app@latest hangs or fails, check your internet connection and ensure Node.js v26.0.0 is active by running node --version.

help me troubleshoot my Expo project creation error

✔️ Awesome, I've got everything!

Great. The scaffolding handled everything for you. No manual file edits needed.

ⓧ I'd like to double check the full code

Here is what app.json should look like:

{
  "expo": {
    "name": "GateGuard",
    "slug": "gateguard",
    "version": "1.0.0",
    "orientation": "portrait",
    "platforms": ["ios", "android"],
    "sdkVersion": "56.0.0"
  }
}

Here is what package.json should look like:

{
  "name": "gateguard",
  "version": "1.0.0",
  "main": "expo/AppEntry.js",
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web"
  },
  "dependencies": {
    "expo": "~56.0.0",
    "react": "18.3.2",
    "react-native": "0.76.9",
    "typescript": "^5.3.3"
  },
  "devDependencies": {
    "@babel/core": "^7.24.0"
  }
}
Install Docker Desktop and verify it's running

Docker Desktop provides a container runtime that you will use to run Keycloak (the authentication server) in a later step. Installing it now means you will not hit a blocker when the backend setup begins.

  • Check if Docker is already on your machine by running:
docker --version

✔️ I see a version number

Docker is already installed. Make sure Docker Desktop is running by looking for the Docker whale icon in your menu bar (macOS) or system tray (Windows).

If the icon is not there, open Docker Desktop using your OS search bar. It may take a moment to start up.

ⓧ Command not found

Docker Desktop is not installed yet. Follow these steps to install it:

  • Open your browser and navigate to https://www.docker.com/products/docker-desktop/.
  • Click Download Docker Desktop. The site auto-detects your operating system.
  • Run the installer and follow the on-screen prompts. Accept default settings.
  • Once installed, launch Docker Desktop. It will ask you to accept the Docker Subscription Service Agreement.
  • Click Accept to start Docker Desktop.
  • Wait for Docker Desktop to finish starting. The whale icon appears in your menu bar (macOS) or system tray (Windows) when it is ready.

Windows users: WSL 2 required

Docker Desktop on Windows uses WSL 2 as its backend. If the installer prompts you to enable WSL, follow the prompts and restart your computer when asked.

After Docker Desktop finishes starting, verify the installation by running:

docker --version

You should see a version number printed, confirming Docker is installed.

  • Confirm Docker is fully operational by running:
docker info

You should see a block of output describing your Docker environment. If you see a "Cannot connect to the Docker daemon" error, Docker Desktop is not running yet.

Cannot connect to the Docker daemon?

This means Docker Desktop has not finished starting. Open Docker Desktop from your applications and wait until the whale icon stops animating.

On macOS, Docker Desktop may request permission to install a helper tool. Click OK and enter your password when prompted.

help me fix my Docker Desktop startup issue

Install Android Studio and configure USB debugging

Android Studio provides the Android SDK and ADB (Android Debug Bridge), which Expo uses to communicate with your physical Android phone during development.

  • Open your browser and navigate to https://developer.android.com/studio.
  • Click Download Android Studio. The site auto-detects your operating system.
  • Accept the license agreement and save the installer.
  • Run the installer and accept default settings for all steps.
  • When the Setup Wizard launches, let it download the required SDK components. This may take several minutes depending on your internet speed.

Why Android Studio if we are using Expo?

Expo handles most build complexity for you, but USB debugging requires the Android SDK and ADB tooling that ships with Android Studio. You will not write code in Android Studio. It provides the device bridge so Expo can push your app directly to your phone.

Now configure your Android phone for USB debugging:

  • On your Android phone, open Settings.
  • Scroll down and tap About phone.
  • Find Build number and tap it seven times. You will see a message saying you are now a developer.
  • Go back to Settings.
  • Tap System, then tap Developer options.
  • Toggle USB debugging on.
  • Connect your phone to your computer with a USB cable.
  • When your phone displays a dialog asking to allow USB debugging, check Always allow from this computer and tap OK.

Cannot find Developer options?

On some phones, Developer options is nested under Settings then System then Advanced. If you do not see it at all, make sure you tapped Build number exactly seven times.

  • Verify the connection by running this in your terminal:
adb devices

✔️ I see my device listed

You should see output with your device serial number and the word device next to it. USB debugging is working.

ⓧ Command not found or empty list

If adb is not found, you need to add the Android SDK platform-tools to your PATH.

  • On macOS, the default path is ~/Library/Android/sdk/platform-tools.
  • On Windows, the default path is C:\Users\YourName\AppData\Local\Android\Sdk\platform-tools.

If the list is empty (no devices shown), check that USB debugging is enabled on your phone, try a different USB cable, or unplug and replug the cable.

help me get adb to detect my Android device

Windows users need a USB driver

If you are on Windows and your device is not detected, you may need to install a USB driver. In Android Studio, open Tools, then SDK Manager.

Select the SDK Tools tab, check Google USB Driver, and click OK.

Your mobile development environment is fully set up. You have the Expo toolchain, Docker Desktop ready for containers, and a direct connection to your Android phone. Next up, you will set up the backend environment with Spring Boot, Keycloak, and your local database.

Set Up the Backend Development Environment

Your mobile app foundation is in place. Now it needs something to talk to.

Every visitor scan, homeowner notification, and access decision flows through a backend API. In this step you will create that API using Spring Boot and set up Keycloak as the identity provider that controls who can do what inside GateGuard.

In this step, get ready to:
  • Create a Spring Boot 4.0.6 project with security and database dependencies.
  • Start Keycloak 26.6.3 in Docker connected to a local PostgreSQL database.
  • Configure the GateGuard realm with roles and social login identity providers.
Create the Spring Boot project

Spring Initializr generates a ready-to-code project with the build tool and dependencies you choose. You will use it to scaffold the gateguard-api backend.

  • Open your browser and navigate to https://start.spring.io.
  • Set **Project** to Maven.
  • Set **Language** to Java.
  • Set **Spring Boot** to 4.0.6.
  • Under **Project Metadata**, set Group to com.nextwork.
  • Set Artifact to gateguard-api.
  • Set Java version to 21.
  • Click Add Dependencies and add each of the following one by one: Spring Web, Spring Security, OAuth2 Resource Server, Spring Data JPA, Liquibase Migration, and PostgreSQL Driver.
  • Click Generate to download the ZIP file.
  • Extract the ZIP to your Desktop so you have a ~/Desktop/gateguard-api folder.

What did Spring Initializr generate?

The generated project contains a Maven pom.xml with all your selected dependencies, a main application class, and an empty application.properties file. You will convert that properties file to YAML shortly.

The pom.xml inherits from spring-boot-starter-parent version 4.0.6, which manages compatible versions for most dependencies automatically.

  • Open IntelliJ IDEA.
  • Click Open and select the gateguard-api folder on your Desktop.
  • IntelliJ will detect the Maven project and begin importing dependencies. Wait for the import to finish (the progress bar in the bottom-right disappears).

Now you need to pin Liquibase to version 5.0.3 explicitly and configure the application.

  • In IntelliJ, open pom.xml from the project root.
  • Find the Liquibase dependency and add an explicit version tag so it looks like this:
<dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
    <version>5.0.3</version>
</dependency>

Why pin the Liquibase version?

Spring Boot manages a default Liquibase version through its BOM. Pinning to 5.0.3 ensures you get the latest community release with the migration features this project relies on.

  • Save pom.xml.
  • Click the Maven reload icon (circular arrows in the top-right of the editor) so IntelliJ downloads the pinned version.

Next, configure the application to connect to the local database and Keycloak.

  • In the src/main/resources folder, rename application.properties to application.yml.
  • Replace its contents with the following configuration:
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/gateguard
    username: postgres
    password: postgres
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8180/realms/gateguard
  liquibase:
    change-log: classpath:db/changelog/db.changelog-master.xml

What does this configuration do?

  • spring.datasource tells Spring Boot to connect to a local PostgreSQL database named gateguard.
  • spring.security.oauth2.resourceserver.jwt.issuer-uri configures the API to validate JWT tokens against the Keycloak realm you will create next.
  • spring.liquibase.change-log tells Liquibase where to find the master changelog file for database migrations.
  • Save application.yml.

IntelliJ not recognizing the YAML file?

Make sure the file is named exactly application.yml (not application.yaml). Both work for Spring Boot, but keep .yml for consistency with this project.

If IntelliJ shows red squiggles, verify the Maven import finished. Click the Maven reload icon again.

Help me debug my application.yml setup in IntelliJ

✔️ Awesome, I've got everything!

Great. Double check you have saved both pom.xml and application.yml.

ⓧ I'd like to double check the full code

Your pom.xml should contain these essential elements (Spring Initializr may include additional boilerplate):

<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.nextwork</groupId>
  <artifactId>gateguard-api</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.0.6</version>
  </parent>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-resource-server</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
    <dependency><groupId>org.liquibase</groupId><artifactId>liquibase-core</artifactId><version>5.0.3</version></dependency>
    <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
  </dependencies>
</project>

Your application.yml should look like this:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/gateguard
    username: postgres
    password: postgres
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8180/realms/gateguard
  liquibase:
    change-log: classpath:db/changelog/db.changelog-master.xml
Start Keycloak in Docker with PostgreSQL

Keycloak is an open-source identity and access management server. It handles user authentication, social login, and role-based access control so you do not have to build those features from scratch.

You will run Keycloak in a Docker container alongside its own PostgreSQL database using Docker Compose.

  • Open a terminal window.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a docker-compose.yml file on your Desktop by running:
touch docker-compose.yml
  • Open docker-compose.yml in IntelliJ IDEA (or any text editor) and paste the following:
version: '3.8'
services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.6.3
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: keycloak
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
    ports:
      - "8180:8080"
    depends_on:
      - keycloak-db
    command: start-dev
  keycloak-db:
    image: postgres:15
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: keycloak

What does this Docker Compose file do?

  • keycloak service runs the official Keycloak 26.6.3 image in development mode (start-dev). It maps port 8180 on your machine to Keycloak's internal port 8080.
  • keycloak-db service runs a PostgreSQL 15 container that Keycloak uses to store its own data (realms, users, sessions).
  • KEYCLOAK_ADMIN and KEYCLOAK_ADMIN_PASSWORD create the initial admin account you will use to log in.

Port 8180 avoids conflicting with Spring Boot's default port 8080.

  • Save docker-compose.yml.
  • Start both containers by running this command from the same directory:
docker compose up -d
  • Wait about 30 seconds for Keycloak to finish starting. Check the logs by running:
docker compose logs keycloak
  • Look for a line containing Keycloak and started in the output. This confirms Keycloak is ready.
  • Open your browser and navigate to http://localhost:8180.
  • You should see the Keycloak welcome page.

Containers not starting?

Make sure Docker Desktop is running. You can verify by running docker ps in your terminal. If you see no output, open Docker Desktop and wait for it to finish starting.

If port 8180 is already in use, check for other services using that port. Stop them or change the port mapping in the compose file.

Help me fix Docker Compose startup errors for Keycloak

Configure the Keycloak realm with roles and social login

A Keycloak realm is an isolated space that manages its own set of users, roles, and applications. You will create a gateguard realm dedicated to this project.

  • In your browser at http://localhost:8180, click Administration Console.
  • Log in with username admin and password admin.
  • You are now in the master realm. Click the realm dropdown in the top-left (it says master).
  • Click Create realm.
  • Enter gateguard as the realm name.
  • Click Create.

Keycloak switches to your new gateguard realm automatically. Now create the three application roles.

  • In the left menu, click Realm roles.
  • Click Create role.
  • Enter GUARD as the role name and click Save.
  • Repeat to create a role named HOMEOWNER.
  • Repeat once more to create a role named ADMIN.

What is Role-Based Access Control?

RBAC restricts access based on roles assigned to users. Guards can scan IDs and log visits. Homeowners can accept or reject visitors. Admins can manage the entire system. Keycloak issues JWTs that include these role claims, and your Spring Boot API checks them on every request.

Next, configure Google as a social login identity provider.

  • In the left menu, click Identity providers.
  • Click Add provider and select Google.
  • You will need a Client ID and Client Secret from the Google Cloud Console. Open https://console.cloud.google.com in a new tab.
  • Create or select a project, then navigate to APIs & Services then Credentials.
  • Click Create Credentials and select OAuth client ID.
  • Set application type to Web application.
  • Add http://localhost:8180/realms/gateguard/broker/google/endpoint as an Authorized Redirect URI.
  • Click Create and copy the Client ID and Client Secret.
  • Back in Keycloak, paste your Google Client ID into the Client ID field.
  • Paste your Google Client Secret into the Client Secret field.
  • Click Save.

Now add Apple as a second identity provider.

  • Return to Identity providers in the left menu.
  • Click Add provider and select Apple.
  • Apple Sign-In requires an Apple Developer account. Enter your Client ID (the Services ID from Apple Developer) and Client Secret (the signed JWT).
  • Click Save.

No Apple Developer account?

Apple Sign-In requires a paid Apple Developer account ($99/year). If you do not have one, skip the Apple provider for now. Google login is sufficient to continue the project. You can add Apple later when you have an account.

Identity provider not saving?

Make sure the gateguard realm is selected (check the realm dropdown in the top-left). If you accidentally configured the provider in the master realm, delete it and switch to the gateguard realm first.

Help me configure identity providers in Keycloak

Your backend foundation is ready. You have a Spring Boot API project configured to validate tokens from Keycloak, and Keycloak is running with roles and social login providers. Next up, you will design the database schema and let Liquibase manage the migrations.

Design the Database Schema with Liquibase

Your backend is connected to Keycloak and your local PostgreSQL database is ready. But the database is empty. Before you can build API endpoints that store visitors, addresses, and visit logs, you need tables to hold that data.

In this step, you will design and create the full database schema using Liquibase. Liquibase tracks every change to your database as versioned XML files called changesets. This means your schema is version-controlled just like your application code.

In this step, get ready to:
  • Create the Liquibase changelog structure with a master changelog and a changesets folder.
  • Write changesets for the six core tables: users, subdivisions, addresses, visitors, visit_logs, and notifications.
  • Run the migrations against your local PostgreSQL database and verify all tables exist.
Create the Liquibase changelog structure

Liquibase needs two things: a master changelog that acts as a table of contents, and individual changeset files that each describe one database change. You will create a folder structure that keeps these organized.

  • Switch back to your terminal inside the gateguard-api project root.
  • Create the changelog directory structure by running this command:
mkdir -p src/main/resources/db/changelog/changesets
  • Confirm the directories exist by running:
ls src/main/resources/db/changelog/

You should see a changesets folder listed in the output.

Why this folder structure?

The master changelog lives at db/changelog/db.changelog-master.xml. This is the path your application.yml already references. Each table gets its own numbered changeset file inside the changesets/ subfolder, making it easy to see the order changes were applied.

  • In IntelliJ IDEA, right-click the db/changelog folder in the Project sidebar, select New then File, and name it db.changelog-master.xml.
  • Paste the following content into db.changelog-master.xml:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
  <!-- Each include references one changeset file, applied in order -->
  <include file="db/changelog/changesets/001-create-users.xml"/>
  <include file="db/changelog/changesets/002-create-subdivisions.xml"/>
  <include file="db/changelog/changesets/003-create-addresses.xml"/>
  <include file="db/changelog/changesets/004-create-visitors.xml"/>
  <include file="db/changelog/changesets/005-create-visit-logs.xml"/>
  <include file="db/changelog/changesets/006-create-notifications.xml"/>
</databaseChangeLog>

What does this file do?

The master changelog is like a playlist. Each <include> element tells Liquibase which changeset file to run and in what order. When Spring Boot starts, Liquibase reads this file and applies any changesets that haven't been run yet.

  • Save db.changelog-master.xml.

IntelliJ not showing the new folder?

If the db/changelog folder does not appear in the Project sidebar, right-click the src/main/resources folder and select Reload from Disk. IntelliJ sometimes needs a manual refresh after terminal-created directories.

Help me find my changelog folder in IntelliJ

Write changesets for the core tables

Your visitor management system needs six tables. Each table gets its own changeset file with a numeric prefix that determines the execution order.

  • In the changesets/ folder, create a new file named 001-create-users.xml and paste the following:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
  <!-- Maps Keycloak users to local app data -->
  <changeSet id="001" author="gateguard">
    <createTable tableName="users">
      <column name="id" type="UUID"><constraints primaryKey="true"/></column>
      <column name="keycloak_id" type="VARCHAR(255)"><constraints unique="true" nullable="false"/></column>
      <column name="email" type="VARCHAR(255)"><constraints unique="true" nullable="false"/></column>
      <column name="full_name" type="VARCHAR(255)"/>
      <column name="role" type="VARCHAR(50)"/>
      <column name="created_at" type="TIMESTAMP"/>
    </createTable>
  </changeSet>
</databaseChangeLog>

What does this table store?

The users table links each Keycloak-authenticated user to your application's data. The keycloak_id column stores the unique identifier from Keycloak, while role mirrors the GUARD, HOMEOWNER, or ADMIN role assigned in the realm.

Each column uses UUID as the primary key type. UUIDs are globally unique without needing a centralized sequence, which matters when your app eventually scales to multiple subdivision deployments.

  • Save 001-create-users.xml.

Seeing red underlines in the XML?

IntelliJ may flag the xmlns namespace as unresolved. This is a cosmetic warning. Liquibase resolves the namespace at runtime, not at edit time. You can safely ignore it.

Help me fix XML warnings in my Liquibase changeset

  • Create 002-create-subdivisions.xml in the changesets/ folder and paste:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
  <!-- Represents a gated subdivision community -->
  <changeSet id="002" author="gateguard">
    <createTable tableName="subdivisions">
      <column name="id" type="UUID"><constraints primaryKey="true"/></column>
      <column name="name" type="VARCHAR(255)"><constraints nullable="false"/></column>
      <column name="address" type="TEXT"/>
      <column name="created_at" type="TIMESTAMP"/>
    </createTable>
  </changeSet>
</databaseChangeLog>

What does this table store?

The subdivisions table holds each gated community that the system manages. A single GateGuard deployment could serve multiple subdivisions, each with its own set of homeowners and addresses.

  • Save 002-create-subdivisions.xml.
  • Create 003-create-addresses.xml in the changesets/ folder and paste:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
  <!-- Links a homeowner to a specific unit within a subdivision -->
  <changeSet id="003" author="gateguard">
    <createTable tableName="addresses">
      <column name="id" type="UUID"><constraints primaryKey="true"/></column>
      <column name="subdivision_id" type="UUID"/>
      <column name="homeowner_id" type="UUID"/>
      <column name="street" type="VARCHAR(255)"/>
      <column name="unit" type="VARCHAR(50)"/>
    </createTable>
  </changeSet>
</databaseChangeLog>

What does this table store?

The addresses table connects a homeowner to their specific unit inside a subdivision. When a guard searches for a homeowner, this table returns matching addresses. The subdivision_id and homeowner_id columns will reference the other tables at the application level.

  • Save 003-create-addresses.xml.
  • Create 004-create-visitors.xml in the changesets/ folder and paste:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
  <!-- Stores visitor identity data extracted by OCR -->
  <changeSet id="004" author="gateguard">
    <createTable tableName="visitors">
      <column name="id" type="UUID"><constraints primaryKey="true"/></column>
      <column name="full_name" type="VARCHAR(255)"/>
      <column name="id_number" type="VARCHAR(100)"/>
      <column name="id_image_url" type="TEXT"/>
      <column name="created_at" type="TIMESTAMP"/>
    </createTable>
  </changeSet>
</databaseChangeLog>

What does this table store?

The visitors table captures the identity of each person arriving at the gate. The full_name and id_number columns hold data extracted by OCR from the visitor's ID. The id_image_url points to the scanned image stored in S3.

  • Save 004-create-visitors.xml.
  • Create 005-create-visit-logs.xml in the changesets/ folder and paste:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
  <!-- Records each visit event and its approval status -->
  <changeSet id="005" author="gateguard">
    <createTable tableName="visit_logs">
      <column name="id" type="UUID"><constraints primaryKey="true"/></column>
      <column name="visitor_id" type="UUID"/>
      <column name="address_id" type="UUID"/>
      <column name="guard_id" type="UUID"/>
      <column name="status" type="VARCHAR(50)"/>
      <column name="rejection_reason" type="TEXT"/>
      <column name="visited_at" type="TIMESTAMP"/>
      <column name="responded_at" type="TIMESTAMP"/>
    </createTable>
  </changeSet>
</databaseChangeLog>

What does this table store?

The visit_logs table is the core transaction table. It links a visitor to the address they are visiting, the guard who logged the visit, and the homeowner's response. The status column tracks whether the visit is PENDING, ACCEPTED, or REJECTED.

  • Save 005-create-visit-logs.xml.
  • Create 006-create-notifications.xml in the changesets/ folder and paste:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
  <!-- Tracks notifications sent to homeowners about visitor arrivals -->
  <changeSet id="006" author="gateguard">
    <createTable tableName="notifications">
      <column name="id" type="UUID"><constraints primaryKey="true"/></column>
      <column name="user_id" type="UUID"/>
      <column name="visit_log_id" type="UUID"/>
      <column name="message" type="TEXT"/>
      <column name="is_read" type="BOOLEAN" defaultValueBoolean="false"/>
      <column name="created_at" type="TIMESTAMP"/>
    </createTable>
  </changeSet>
</databaseChangeLog>

What does this table store?

The notifications table stores alerts sent to homeowners when a visitor arrives. The is_read column defaults to false so the app can show unread notification counts. Each notification links back to a specific visit_log_id so the homeowner can respond directly.

  • Save 006-create-notifications.xml.

✔️ Awesome, I've got everything!

Great. Double check that you have 6 XML files in src/main/resources/db/changelog/changesets/ and that db.changelog-master.xml is saved in the parent db/changelog/ folder.

ⓧ I'd like to double check the full code

Here is the complete master changelog that references all six changesets:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
  <include file="db/changelog/changesets/001-create-users.xml"/>
  <include file="db/changelog/changesets/002-create-subdivisions.xml"/>
  <include file="db/changelog/changesets/003-create-addresses.xml"/>
  <include file="db/changelog/changesets/004-create-visitors.xml"/>
  <include file="db/changelog/changesets/005-create-visit-logs.xml"/>
  <include file="db/changelog/changesets/006-create-notifications.xml"/>
</databaseChangeLog>

Your changesets/ folder should contain these 6 files:

  • 001-create-users.xml
  • 002-create-subdivisions.xml
  • 003-create-addresses.xml
  • 004-create-visitors.xml
  • 005-create-visit-logs.xml
  • 006-create-notifications.xml
Run the migrations and verify the schema

Your changeset files define what the schema should look like. Now you need to run the Spring Boot application so Liquibase can apply them to your local PostgreSQL database.

  • Make sure your local PostgreSQL service is running.
  • Make sure your Keycloak Docker container is running (Liquibase does not need Keycloak, but Spring Boot will fail to start if it cannot reach the issuer URI).
  • Start the Spring Boot application by running this command from the gateguard-api directory:
./mvnw spring-boot:run

What happens when Spring Boot starts?

Spring Boot detects the spring-boot-starter-liquibase dependency and reads the spring.liquibase.change-log property from application.yml. It finds your master changelog, walks through each included changeset in order, and applies them to the gateguard database.

Liquibase also creates its own tracking table called DATABASECHANGELOG in your database. This table records which changesets have already been applied, so re-running the app never duplicates tables.

You should see log lines mentioning each changeset being applied. Once the application finishes starting, you can verify the tables.

  • Open a new terminal tab and connect to your gateguard database by running:
psql -U postgres -d gateguard
  • List all tables by running:
\dt

You should see 8 tables listed: your 6 application tables (users, subdivisions, addresses, visitors, visit_logs, notifications) plus Liquibase's two tracking tables (databasechangelog and databasechangeloglock).

Spring Boot failing to start?

If you see a connection refused error mentioning port 5432, your local PostgreSQL service is not running. Start it before retrying.

If you see an error mentioning the issuer URI on port 8180, your Keycloak Docker container is not running. Start it with docker compose up -d from the directory containing your docker-compose.yml.

If Liquibase reports an XML parsing error, check that every changeset file starts with <?xml version="1.0" encoding="UTF-8"?> and that all tags are properly closed.

Help me debug my Liquibase migration failure

  • Exit psql by typing \q and pressing Enter.

Your database schema is in place and fully version-controlled. Next up, you will build the Spring Boot REST API endpoints that read and write to these tables.

Build the Spring Boot REST API

Your database schema is ready and Keycloak is handling authentication. Now it's time to build the API layer that connects your mobile app to the backend.

In this step, you'll create the REST endpoints that the guard and homeowner apps will call. You'll also wire in Spring Security so that only the right roles can access the right endpoints.

In this step, get ready to:
  • Create REST controllers for visitor management (log visits, search addresses, respond to visits).
  • Implement an OCR endpoint that calls PaddleOCR's free cloud API.
  • Configure Spring Security as an OAuth2 Resource Server with role-based endpoint protection.
Create the visit management controller

The guard app needs to log visits when a visitor arrives, search for homeowner addresses, and allow homeowners to accept or reject visitors. These three operations map to three REST endpoints in a single controller.

  • In IntelliJ IDEA, right-click the src/main/java/com/nextwork/gateguard package in the Project tool window.
  • Select New then Package.
  • Name the package controller and press Enter.
  • Right-click the new controller package and select New then Java Class.
  • Name the class VisitController and press Enter.
  • Replace the file contents with the following code:
package com.nextwork.gateguard.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

// Handles visit logging, address search, and visit responses
@RestController
@RequestMapping
public class VisitController {

    // Guard logs a new visit when a visitor arrives at the gate
    @PostMapping("/visits")
    public ResponseEntity<VisitLog> logVisit(@RequestBody LogVisitRequest request) {
        // TODO: Save visitor info and create visit log entry
        return ResponseEntity.ok(new VisitLog());
    }

    // Guard searches for the homeowner's address
    @GetMapping("/addresses")
    public ResponseEntity<List<Address>> searchAddresses(@RequestParam String query) {
        // TODO: Query addresses table by street or homeowner name
        return ResponseEntity.ok(List.of());
    }

    // Homeowner accepts or rejects a visitor
    @PostMapping("/visits/{id}/respond")
    public ResponseEntity<VisitLog> respondToVisit(@PathVariable UUID id, @RequestBody RespondRequest request) {
        // TODO: Update visit log status and notify guard
        return ResponseEntity.ok(new VisitLog());
    }
}

What does this code do?

This controller defines the three core endpoints your mobile app will call.

  • POST /visits is called by the guard app when a visitor arrives. It receives the visitor's details and creates a log entry.
  • GET /addresses lets the guard search for which homeowner the visitor wants to see. The query parameter filters by street name or homeowner name.
  • POST /visits/{id}/respond is called by the homeowner app to accept or reject a visitor. The id path variable identifies which visit log entry to update.

Why stub the method bodies?

The TODO comments mark where service layer logic will go. Right now you're defining the API contract (the URLs, HTTP methods, request/response shapes) so the mobile app knows what to call. The implementation details come after the contract is locked in.

  • Save VisitController.java by pressing Cmd+S (macOS) or Ctrl+S (Windows).

DTO classes are placeholders for now

The compiler will flag VisitLog, LogVisitRequest, Address, and RespondRequest as unresolved. You'll create these DTO and entity classes as you build out the service layer. For now, the controller structure is what matters.

Implement the OCR endpoint

When the guard's on-device OCR produces unclear results (damaged IDs, unusual fonts), the app falls back to a server-side OCR call. Your backend calls PaddleOCR's free cloud API, which supports up to 20,000 pages per day using the PP-OCRv5 model.

  • Right-click the controller package and select New then Java Class.
  • Name the class OcrController and press Enter.
  • Replace the file contents with the following code:
package com.nextwork.gateguard.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

// Handles backend OCR fallback when on-device ML Kit results are poor
@RestController
@RequestMapping("/ocr")
public class OcrController {

    // Receives an ID image and returns extracted text via PaddleOCR cloud API
    @PostMapping("/extract")
    public ResponseEntity<OcrResult> extractText(@RequestParam("image") MultipartFile image) {
        // TODO: Send image to PaddleOCR cloud API (PP-OCRv5)
        // TODO: Parse response and return structured text (name, ID number)
        return ResponseEntity.ok(new OcrResult());
    }
}

What does this endpoint do?

The POST /ocr/extract endpoint receives a photo of a visitor's ID as a multipart file upload. It sends that image to PaddleOCR's free cloud API and returns the extracted text (visitor name, ID number) in a structured format.

PaddleOCR's cloud API uses the PP-OCRv5 model and allows 20,000 pages per day for free. Your Spring Boot app authenticates with an access token stored in the PADDLEOCR_ACCESS_TOKEN environment variable.

Why a backend OCR fallback?

The mobile app runs Google ML Kit on-device for instant OCR. But damaged IDs or unusual fonts can produce unreliable results. When that happens, the app sends the image to this endpoint for a second opinion from PaddleOCR's cloud service.

This dual-layer approach means the system works even when on-device processing struggles.

  • Save OcrController.java.

Seeing red squiggles on OcrResult?

This is expected. The OcrResult class doesn't exist yet. You'll define it when you implement the service layer. For now, focus on getting the controller structure right.

Help me debug my OcrController compile errors

Configure Spring Security with role-based protection

Your API endpoints exist, but right now anyone can call them. You need to lock them down so only guards can log visits and scan IDs, and only homeowners can respond to visit requests.

Spring Security's OAuth2 Resource Server validates the JWT tokens that Keycloak issues. It checks the token's signature and extracts the user's roles to decide whether to allow the request.

  • Right-click the src/main/java/com/nextwork/gateguard package and select New then Package.
  • Name the package config and press Enter.
  • Right-click the config package and select New then Java Class.
  • Name the class SecurityConfig and press Enter.
  • Replace the file contents with the following code:
package com.nextwork.gateguard.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

// Configures endpoint access rules and JWT validation via Keycloak
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // Define which roles can access which endpoints
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.POST, "/visits").hasRole("GUARD")
                .requestMatchers(HttpMethod.GET, "/addresses").hasAnyRole("GUARD", "ADMIN")
                .requestMatchers(HttpMethod.POST, "/visits/*/respond").hasRole("HOMEOWNER")
                .requestMatchers(HttpMethod.POST, "/ocr/extract").hasRole("GUARD")
                .anyRequest().authenticated()
            )
            // Validate JWTs issued by Keycloak using the issuer-uri in application.yml
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}

What does this configuration do?

  • authorizeHttpRequests defines which Keycloak roles can access each endpoint. Guards can log visits and extract OCR text. Homeowners can respond to visit requests. Admins can also search addresses.
  • anyRequest().authenticated() means any endpoint not explicitly listed still requires a valid JWT. No anonymous access allowed.
  • oauth2ResourceServer tells Spring Security to validate incoming JWTs. The Customizer.withDefaults() call uses the spring.security.oauth2.resourceserver.jwt.issuer-uri from your application.yml to fetch Keycloak's public keys and verify token signatures.

How does Keycloak connect to Spring Security?

When a user logs in through the mobile app, Keycloak issues a JWT containing their roles (GUARD, HOMEOWNER, or ADMIN). The mobile app sends this JWT as a Bearer token in every API request.

Spring Security intercepts the request, validates the JWT against Keycloak's public keys, and checks whether the user's role matches the endpoint's access rule.

  • Save SecurityConfig.java.
  • Make sure your Keycloak Docker container and PostgreSQL database are running from the previous step.
  • Run the Spring Boot application by clicking the green Run button in IntelliJ IDEA (or press Shift+F10 on Windows / Ctrl+R on macOS).

You should see Spring Boot start up on port 8080. The console output will include a line confirming that the OAuth2 resource server is configured.

Application won't start?

If you see a connection refused error, make sure Keycloak is running on port 8180. Spring Security tries to fetch Keycloak's public keys at startup using the issuer-uri in application.yml.

If you see a database connection error, confirm PostgreSQL is running and the gateguard database exists.

Help me debug my Spring Boot startup error

✔️ Awesome, I've got everything!

Great. Double check you've saved all three new files before moving on.

ⓧ I'd like to double check the full code

Here is the full SecurityConfig.java for comparison:

package com.nextwork.gateguard.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.POST, "/visits").hasRole("GUARD")
                .requestMatchers(HttpMethod.GET, "/addresses").hasAnyRole("GUARD", "ADMIN")
                .requestMatchers(HttpMethod.POST, "/visits/*/respond").hasRole("HOMEOWNER")
                .requestMatchers(HttpMethod.POST, "/ocr/extract").hasRole("GUARD")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}

Here is the full VisitController.java for comparison:

package com.nextwork.gateguard.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

@RestController
@RequestMapping
public class VisitController {

    @PostMapping("/visits")
    public ResponseEntity<VisitLog> logVisit(@RequestBody LogVisitRequest request) {
        return ResponseEntity.ok(new VisitLog());
    }

    @GetMapping("/addresses")
    public ResponseEntity<List<Address>> searchAddresses(@RequestParam String query) {
        return ResponseEntity.ok(List.of());
    }

    @PostMapping("/visits/{id}/respond")
    public ResponseEntity<VisitLog> respondToVisit(@PathVariable UUID id, @RequestBody RespondRequest request) {
        return ResponseEntity.ok(new VisitLog());
    }
}

Here is the full OcrController.java for comparison:

package com.nextwork.gateguard.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/ocr")
public class OcrController {

    @PostMapping("/extract")
    public ResponseEntity<OcrResult> extractText(@RequestParam("image") MultipartFile image) {
        return ResponseEntity.ok(new OcrResult());
    }
}

Your API endpoints are locked down with role-based protection. Next up, you'll build the React Native mobile app that connects to these endpoints through Keycloak authentication.

Build the Mobile App Authentication

Your Spring Boot API is running and secured by Keycloak. The backend knows how to validate JWTs and enforce role-based access.

Now the mobile app needs to speak the same language. In this step, you will connect the React Native app to Keycloak using OAuth2/OIDC, implement social login buttons, and route users to different screens based on their role.

In this step, get ready to:
  • Configure Expo with expo-auth-session to connect to Keycloak for login.
  • Implement the login flow with Google/Apple social login buttons.
  • Build role-based navigation so Guard and Homeowner users see different screens.
Configure Expo with expo-auth-session to connect to Keycloak

Before the mobile app can authenticate users, it needs packages that handle the OAuth2 browser-based flow. It also needs a deep link scheme so the browser can redirect back to your app after login.

  • Switch back to your terminal in the GateGuard project folder.
  • Install the authentication and navigation packages by running this command:
npx expo install expo-auth-session expo-crypto expo-web-browser expo-mlkit-ocr @react-navigation/native @react-navigation/bottom-tabs @react-navigation/stack

What do these packages do?

  • expo-auth-session handles the OAuth2/OIDC browser-based login flow.
  • expo-crypto is a peer dependency required by expo-auth-session for PKCE code challenges.
  • expo-web-browser opens the system browser for the login page and handles closing it after redirect.
  • expo-mlkit-ocr provides on-device OCR that you will use in the next step for ID scanning.
  • The @react-navigation packages handle screen routing based on the user's role.
  • Confirm the packages installed by checking that expo-auth-session and expo-mlkit-ocr appear in your package.json dependencies.

Install command failing?

Make sure you are running the command from inside the GateGuard folder, not from a parent directory. Run ls and confirm you see package.json in the output.

Help me fix an expo install error in my GateGuard project

  • Open app.json in the GateGuard project folder.
  • Replace the contents with this configuration to add a deep link scheme:
{
  "expo": {
    "name": "GateGuard",
    "slug": "gateguard",
    "version": "1.0.0",
    "orientation": "portrait",
    "platforms": ["ios", "android"],
    "sdkVersion": "56.0.0",
    "scheme": "gateguard"
  }
}

Why do you need a scheme?

The scheme field registers a custom URL protocol for your app (gateguard://). After the user logs in through the browser, Keycloak redirects back to gateguard://auth which opens your app and completes the authentication flow.

  • Save app.json.

Now you need to register a client in Keycloak that the mobile app can authenticate against.

  • Open your browser and navigate to http://localhost:8180 to access the Keycloak Admin Console.
  • Log in with username admin and password admin.
  • Select the gateguard realm from the dropdown in the top-left corner.
  • Click Clients in the left sidebar.
  • Click Create client.
  • Leave Client type set to OpenID Connect.
  • Enter gateguard-mobile in the Client ID field.
  • Click Next.
  • On the Capability config page, turn Client authentication to Off (this makes it a public client, which is correct for mobile apps).
  • Click Next.
  • In the Valid redirect URIs field, enter gateguard://auth.
  • Click Save.

What is a public client?

Mobile apps cannot securely store a client secret because the code runs on the user's device. A public client uses PKCE (Proof Key for Code Exchange) instead of a secret to secure the authorization flow.

Keycloak handles PKCE automatically when client authentication is turned off.

You should see the gateguard-mobile client listed on the Settings tab with your redirect URI saved.

Don't see the client after saving?

Make sure you selected the gateguard realm (not the master realm) before creating the client. Check the realm dropdown in the top-left corner of the Admin Console.

Help me troubleshoot my Keycloak client configuration

Implement the login flow with social login buttons

With the Keycloak client ready, you can build the login screen. The screen uses expo-auth-session to open a browser-based login page hosted by Keycloak, which includes the Google and Apple social login options you configured earlier.

  • Create the folder src/screens inside your GateGuard project.
  • Create a new file called LoginScreen.tsx inside src/screens and add the following code:
import React from 'react';
import { View, Button, Text } from 'react-native';
import * as WebBrowser from 'expo-web-browser';
import { useAuthRequest } from 'expo-auth-session';

// Closes the browser popup when the auth flow redirects back
WebBrowser.maybeCompleteAuthSession();

// Points to your local Keycloak realm
const KEYCLOAK_URL = 'http://localhost:8180/realms/gateguard';

export default function LoginScreen() {
  // Configure the OAuth2 authorization request
  const [request, response, promptAsync] = useAuthRequest(
    {
      clientId: 'gateguard-mobile',
      redirectUri: 'gateguard://auth',
      scopes: ['openid', 'profile', 'email'],
    },
    { authorizationEndpoint: `${KEYCLOAK_URL}/protocol/openid-connect/auth` }
  );

  return (
    <View>
      <Text>GateGuard</Text>
      <Button title="Login with Google" onPress={() => promptAsync()} />
      <Button title="Login with Apple" onPress={() => promptAsync()} />
    </View>
  );
}

What does this code do?

  • WebBrowser.maybeCompleteAuthSession() ensures the browser popup closes automatically when Keycloak redirects back to your app.
  • useAuthRequest creates an OAuth2 authorization request with your client ID, redirect URI, and requested scopes.
  • promptAsync() opens the system browser to the Keycloak login page. Since Google and Apple identity providers are configured in the realm, both options appear on the Keycloak login page.
  • Both buttons call the same promptAsync() because Keycloak's hosted login page handles provider selection. The separate buttons give the user clear UX choices.
  • Save LoginScreen.tsx.

Seeing TypeScript import errors?

Make sure you ran the install command from the previous substep. The expo-auth-session and expo-web-browser packages must be installed for the imports to resolve.

Help me fix TypeScript import errors in my LoginScreen

Build role-based navigation

When a user logs in, the JWT from Keycloak contains their role (GUARD or HOMEOWNER). The app needs to read this role and show the correct set of screens.

  • Create the folder src/hooks inside GateGuard.
  • Create a new file called useAuth.ts inside src/hooks and add this code:
import { useState } from 'react';

// Custom hook to store authentication state
export function useAuth() {
  const [user, setUser] = useState(null);
  const [role, setRole] = useState<string | null>(null);
  const [token, setToken] = useState<string | null>(null);
  return { user, role, token, setUser, setRole, setToken };
}

What does this hook do?

The useAuth hook stores three pieces of state: the authenticated user object, the user's role extracted from the JWT, and the raw token for API calls.

You will expand this hook later to include the full token exchange logic.

  • Save useAuth.ts.
  • Create the folder src/navigation inside GateGuard.
  • Create a new file called AppNavigator.tsx inside src/navigation and add this code:
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import GuardNavigator from './GuardNavigator';
import HomeownerNavigator from './HomeownerNavigator';
import LoginScreen from '../screens/LoginScreen';
import { useAuth } from '../hooks/useAuth';

// Root navigator that routes based on auth state and role
export default function AppNavigator() {
  const { user, role } = useAuth();

  // Not logged in: show login screen
  if (!user) return <LoginScreen />;

  // Route to the correct tab stack based on role
  if (role === 'GUARD') return <GuardNavigator />;
  if (role === 'HOMEOWNER') return <HomeownerNavigator />;

  return null;
}

How does role-based routing work?

The AppNavigator component checks two conditions. If there is no authenticated user, it renders the LoginScreen. Once the user logs in, the JWT's role claim determines which navigator to show.

Guards get the GuardNavigator (with scan ID, search homeowner, and visit log screens). Homeowners get the HomeownerNavigator (with visitor alerts and visit history screens). You will build these navigators in the next steps.

  • Save AppNavigator.tsx.

You won't see the navigation working yet because the GuardNavigator and HomeownerNavigator components don't exist yet. You will build those in the next step.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: app.json, LoginScreen.tsx, useAuth.ts, and AppNavigator.tsx.

ⓧ I'd like to double check the full code

Here is the full code for each file you created or modified in this step.

{
  "expo": {
    "name": "GateGuard",
    "slug": "gateguard",
    "version": "1.0.0",
    "orientation": "portrait",
    "platforms": ["ios", "android"],
    "sdkVersion": "56.0.0",
    "scheme": "gateguard"
  }
}
import React from 'react';
import { View, Button, Text } from 'react-native';
import * as WebBrowser from 'expo-web-browser';
import { useAuthRequest } from 'expo-auth-session';

WebBrowser.maybeCompleteAuthSession();

const KEYCLOAK_URL = 'http://localhost:8180/realms/gateguard';

export default function LoginScreen() {
  const [request, response, promptAsync] = useAuthRequest(
    {
      clientId: 'gateguard-mobile',
      redirectUri: 'gateguard://auth',
      scopes: ['openid', 'profile', 'email'],
    },
    { authorizationEndpoint: `${KEYCLOAK_URL}/protocol/openid-connect/auth` }
  );

  return (
    <View>
      <Text>GateGuard</Text>
      <Button title="Login with Google" onPress={() => promptAsync()} />
      <Button title="Login with Apple" onPress={() => promptAsync()} />
    </View>
  );
}
import { useState } from 'react';

export function useAuth() {
  const [user, setUser] = useState(null);
  const [role, setRole] = useState<string | null>(null);
  const [token, setToken] = useState<string | null>(null);
  return { user, role, token, setUser, setRole, setToken };
}
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import GuardNavigator from './GuardNavigator';
import HomeownerNavigator from './HomeownerNavigator';
import LoginScreen from '../screens/LoginScreen';
import { useAuth } from '../hooks/useAuth';

export default function AppNavigator() {
  const { user, role } = useAuth();
  if (!user) return <LoginScreen />;
  if (role === 'GUARD') return <GuardNavigator />;
  if (role === 'HOMEOWNER') return <HomeownerNavigator />;
  return null;
}

Your mobile app now has a complete authentication flow wired up to Keycloak. Next, you will build the Guard-specific screens for scanning visitor IDs and logging visits.

Build the Guard Features in the Mobile App

Your app has login and role-based navigation working. Now it is time to build the screens the security guard actually uses on duty.

Guards need to scan visitor IDs quickly, extract text from the ID photo, and then select which homeowner the visitor is going to see. You will build two screens that handle this entire flow using on-device OCR and a backend fallback.

In this step, get ready to:
  • Integrate expo-camera with expo-mlkit-ocr for on-device ID scanning.
  • Build the Scan ID screen with camera capture, OCR text extraction, and editable result fields.
  • Build the Select Homeowner screen with address search and visit submission.
Set up expo-camera and update the Guard navigator

The guard needs camera access to photograph visitor IDs. The expo-camera package provides a native camera component that works with your Expo SDK 56 project.

  • Install expo-camera by running this command in your GateGuard project folder:
npx expo install expo-camera

Why npx expo install instead of npm install?

The npx expo install command automatically selects a version of expo-camera that is compatible with your current Expo SDK version. Using npm install directly could pull a mismatched version and cause build errors.

Install failed?

Make sure your terminal is inside the GateGuard folder (the one containing package.json). If you see permission errors, try prefixing the command with sudo on macOS or running your terminal as Administrator on Windows.

Help me troubleshoot expo-camera installation errors in my Expo SDK 56 project.

Next, you need to register the camera plugin in your app configuration so the device knows to prompt for camera permissions.

  • Open GateGuard/app.json and replace its contents with the following:
{
  "expo": {
    "name": "GateGuard",
    "slug": "gateguard",
    "version": "1.0.0",
    "orientation": "portrait",
    "platforms": ["ios", "android"],
    "sdkVersion": "56.0.0",
    "scheme": "gateguard",
    "plugins": [["expo-camera", {"cameraPermission": "Allow GateGuard to access your camera to scan visitor IDs."}]]
  }
}

What does the plugins array do?

The plugins array tells Expo's config plugin system to inject native camera permissions into your Android and iOS builds automatically. The cameraPermission string is the message the user sees when the OS asks for camera access.

  • Save app.json.

Now you will update the Guard navigator to register the two screens you are about to build.

  • Create the guard subdirectory inside GateGuard/src/screens/ by running:
mkdir -p GateGuard/src/screens/guard
  • Open GateGuard/src/navigation/GuardNavigator.tsx and replace its contents with the following:
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import ScanIdScreen from '../screens/guard/ScanIdScreen';
import SelectHomeownerScreen from '../screens/guard/SelectHomeownerScreen';

// Create a stack navigator for the guard flow
const Stack = createStackNavigator();

export default function GuardNavigator() {
  return (
    <Stack.Navigator initialRouteName="ScanId">
      <Stack.Screen name="ScanId" component={ScanIdScreen} />
      <Stack.Screen name="SelectHomeowner" component={SelectHomeownerScreen} />
    </Stack.Navigator>
  );
}

What does this navigator do?

The GuardNavigator defines a two-screen stack. The guard starts on ScanId (scanning a visitor's ID), then navigates forward to SelectHomeowner (choosing which homeowner the visitor is going to see).

  • Save GuardNavigator.tsx.
  • Your editor will show import errors because the screen files do not exist yet. That is expected. You will create them next.

Seeing unexpected TypeScript errors?

Confirm you created the guard folder in the correct location: GateGuard/src/screens/guard/. If the folder path is wrong, the imports will never resolve.

Help me fix TypeScript import path errors in my GuardNavigator.

Build the Scan ID screen

This is the guard's primary screen. It opens the camera, lets the guard photograph a visitor's ID, and then uses Google ML Kit (via expo-mlkit-ocr) to extract text from the image on-device.

If the on-device OCR fails (damaged ID, bad lighting), the image is sent to your Spring Boot backend which calls PaddleOCR's free cloud API as a fallback.

  • Create a new file at GateGuard/src/screens/guard/ScanIdScreen.tsx and paste the following code:
import React, { useState, useRef } from 'react';
import { View, Text, Button, TextInput } from 'react-native';
import { Camera } from 'expo-camera';
import MlkitOcr from 'expo-mlkit-ocr';
import { useAuth } from '../../hooks/useAuth';

export default function ScanIdScreen({ navigation }: any) {
  const { token } = useAuth();
  const cameraRef = useRef(null);
  // Store the extracted fields from the visitor's ID
  const [ocrFields, setOcrFields] = useState({ full_name: '', id_number: '', address: '' });

  const captureAndScan = async () => {
    // Take a photo of the visitor's ID
    const photo = await cameraRef.current.takePictureAsync({ base64: true });
    try {
      // Attempt on-device OCR using ML Kit
      const result = await MlkitOcr.detectFromUri(photo.uri);
      // Parse result blocks into ocrFields
    } catch {
      // Fallback: POST /ocr/extract
      const formData = new FormData();
      formData.append('image', { uri: photo.uri, type: 'image/jpeg', name: 'id.jpg' } as any);
      const res = await fetch('http://localhost:8080/ocr/extract', {
        method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData
      });
      const data = await res.json();
      setOcrFields(data);
    }
  };

  return (
    <View>
      <Camera ref={cameraRef} style={{ height: 300 }} />
      <Button title="Scan ID" onPress={captureAndScan} />
      <TextInput value={ocrFields.full_name} onChangeText={v => setOcrFields(f => ({...f, full_name: v}))} placeholder="Full Name" />
      <TextInput value={ocrFields.id_number} onChangeText={v => setOcrFields(f => ({...f, id_number: v}))} placeholder="ID Number" />
      <TextInput value={ocrFields.address} onChangeText={v => setOcrFields(f => ({...f, address: v}))} placeholder="Address" />
      <Button title="Next: Select Homeowner" onPress={() => navigation.navigate('SelectHomeowner', { ocrFields })} />
    </View>
  );
}

What does this screen do?

The Camera component renders a live viewfinder. When the guard taps **Scan ID**, takePictureAsync captures the photo and MlkitOcr.detectFromUri runs OCR entirely on the phone with no network call.

If the on-device scan throws an error (the catch block), a FormData payload is sent to your Spring Boot POST /ocr/extract endpoint. That endpoint forwards the image to PaddleOCR's cloud API and returns structured text.

The three TextInput fields let the guard correct any OCR mistakes before moving to the next screen.

Why two OCR layers?

On-device ML Kit handles 90%+ of scans instantly with zero network latency. But damaged IDs or unusual fonts sometimes produce garbage output. The PaddleOCR fallback uses a more powerful server-side model (PP-OCRv5) that handles edge cases better.

This dual-layer approach gives you speed in the common case and accuracy in the hard case, without needing GPU infrastructure on your server.

  • Save ScanIdScreen.tsx.

This screen requires a development build

Both expo-camera and expo-mlkit-ocr use native modules that cannot run in Expo Go. You must build a development client using npx expo run:android with your Android phone connected via USB to test this screen.

Camera shows a black screen?

Make sure you granted camera permissions when the OS prompt appeared. If you dismissed it, go to your phone's Settings, find GateGuard, and enable Camera access manually.

Also confirm you are running a development build, not Expo Go. The Camera component renders nothing in Expo Go.

Help me debug why my expo-camera shows a black screen in my development build.

Build the Select Homeowner screen

After scanning the ID, the guard needs to select which homeowner the visitor is going to see. This screen searches your backend's address list and submits the visit.

  • Create a new file at GateGuard/src/screens/guard/SelectHomeownerScreen.tsx and paste the following code:
import React, { useState } from 'react';
import { View, Text, TextInput, FlatList, TouchableOpacity, Button } from 'react-native';
import { useAuth } from '../../hooks/useAuth';

export default function SelectHomeownerScreen({ route, navigation }: any) {
  const { ocrFields } = route.params;
  const { token } = useAuth();
  const [query, setQuery] = useState('');
  const [addresses, setAddresses] = useState([]);
  const [selected, setSelected] = useState(null);

  // Search for addresses matching the guard's query
  const search = async () => {
    const res = await fetch(`http://localhost:8080/addresses?query=${query}`, {
      headers: { Authorization: `Bearer ${token}` }
    });
    setAddresses(await res.json());
  };

  // Submit the visit to the backend
  const submitVisit = async () => {
    await fetch('http://localhost:8080/visits', {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ addressId: selected.id, visitorInfo: ocrFields })
    });
    navigation.navigate('ScanId');
  };

  return (
    <View>
      <TextInput value={query} onChangeText={setQuery} placeholder="Search address or homeowner" />
      <Button title="Search" onPress={search} />
      <FlatList data={addresses} keyExtractor={i => i.id} renderItem={({ item }) => (
        <TouchableOpacity onPress={() => setSelected(item)}>
          <Text>{item.street} {item.unit}</Text>
        </TouchableOpacity>
      )} />
      {selected && <Button title="Submit Visit" onPress={submitVisit} />}
    </View>
  );
}

What does this screen do?

The guard types a street name or homeowner name into the search field. Tapping **Search** calls your Spring Boot GET /addresses endpoint with the query string. The results appear in a FlatList.

When the guard taps an address, it is stored in selected state and a **Submit Visit** button appears. Pressing it sends a POST /visits request with the selected address ID and the OCR fields from the previous screen, then navigates back to ScanId so the guard is ready for the next visitor.

  • Save SelectHomeownerScreen.tsx.
  • Run your development build on your Android phone. Log in as a guard user. You should see the Scan ID screen with a camera preview.
  • Tap **Scan ID**, then tap **Next: Select Homeowner**. The Select Homeowner screen should load with a search field.

Network request failed on the Select Homeowner screen?

Your phone cannot reach localhost:8080 because that refers to the phone itself. Replace localhost with your computer's local IP address (find it with ifconfig on macOS or ipconfig on Windows). Both devices must be on the same Wi-Fi network.

Also confirm your Spring Boot API is running and Keycloak is up so the JWT token validates successfully.

Help me connect my React Native app on my phone to my local Spring Boot backend.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: GuardNavigator.tsx, ScanIdScreen.tsx, and SelectHomeownerScreen.tsx.

ⓧ I'd like to double check the full code

Here are the complete files for this step.

{
  "expo": {
    "name": "GateGuard",
    "slug": "gateguard",
    "version": "1.0.0",
    "orientation": "portrait",
    "platforms": ["ios", "android"],
    "sdkVersion": "56.0.0",
    "scheme": "gateguard",
    "plugins": [["expo-camera", {"cameraPermission": "Allow GateGuard to access your camera to scan visitor IDs."}]]
  }
}
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import ScanIdScreen from '../screens/guard/ScanIdScreen';
import SelectHomeownerScreen from '../screens/guard/SelectHomeownerScreen';

const Stack = createStackNavigator();
export default function GuardNavigator() {
  return (
    <Stack.Navigator initialRouteName="ScanId">
      <Stack.Screen name="ScanId" component={ScanIdScreen} />
      <Stack.Screen name="SelectHomeowner" component={SelectHomeownerScreen} />
    </Stack.Navigator>
  );
}
import React, { useState, useRef } from 'react';
import { View, Text, Button, TextInput } from 'react-native';
import { Camera } from 'expo-camera';
import MlkitOcr from 'expo-mlkit-ocr';
import { useAuth } from '../../hooks/useAuth';

export default function ScanIdScreen({ navigation }: any) {
  const { token } = useAuth();
  const cameraRef = useRef(null);
  const [ocrFields, setOcrFields] = useState({ full_name: '', id_number: '', address: '' });

  const captureAndScan = async () => {
    const photo = await cameraRef.current.takePictureAsync({ base64: true });
    try {
      const result = await MlkitOcr.detectFromUri(photo.uri);
      // Parse result blocks into ocrFields
    } catch {
      // Fallback: POST /ocr/extract
      const formData = new FormData();
      formData.append('image', { uri: photo.uri, type: 'image/jpeg', name: 'id.jpg' } as any);
      const res = await fetch('http://localhost:8080/ocr/extract', {
        method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData
      });
      const data = await res.json();
      setOcrFields(data);
    }
  };

  return (
    <View>
      <Camera ref={cameraRef} style={{ height: 300 }} />
      <Button title="Scan ID" onPress={captureAndScan} />
      <TextInput value={ocrFields.full_name} onChangeText={v => setOcrFields(f => ({...f, full_name: v}))} placeholder="Full Name" />
      <TextInput value={ocrFields.id_number} onChangeText={v => setOcrFields(f => ({...f, id_number: v}))} placeholder="ID Number" />
      <TextInput value={ocrFields.address} onChangeText={v => setOcrFields(f => ({...f, address: v}))} placeholder="Address" />
      <Button title="Next: Select Homeowner" onPress={() => navigation.navigate('SelectHomeowner', { ocrFields })} />
    </View>
  );
}
import React, { useState } from 'react';
import { View, Text, TextInput, FlatList, TouchableOpacity, Button } from 'react-native';
import { useAuth } from '../../hooks/useAuth';

export default function SelectHomeownerScreen({ route, navigation }: any) {
  const { ocrFields } = route.params;
  const { token } = useAuth();
  const [query, setQuery] = useState('');
  const [addresses, setAddresses] = useState([]);
  const [selected, setSelected] = useState(null);

  const search = async () => {
    const res = await fetch(`http://localhost:8080/addresses?query=${query}`, {
      headers: { Authorization: `Bearer ${token}` }
    });
    setAddresses(await res.json());
  };

  const submitVisit = async () => {
    await fetch('http://localhost:8080/visits', {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ addressId: selected.id, visitorInfo: ocrFields })
    });
    navigation.navigate('ScanId');
  };

  return (
    <View>
      <TextInput value={query} onChangeText={setQuery} placeholder="Search address or homeowner" />
      <Button title="Search" onPress={search} />
      <FlatList data={addresses} keyExtractor={i => i.id} renderItem={({ item }) => (
        <TouchableOpacity onPress={() => setSelected(item)}>
          <Text>{item.street} {item.unit}</Text>
        </TouchableOpacity>
      )} />
      {selected && <Button title="Submit Visit" onPress={submitVisit} />}
    </View>
  );
}

The guard's workflow is complete. They can scan IDs, review extracted text, search for a homeowner, and submit the visit in seconds. Next up, you will build the homeowner's side of the app so they can receive visitor notifications and respond in real time.

Build the Homeowner Features in the Mobile App

The guard side of GateGuard can scan IDs and submit visit requests. Now you need to build the other half of the conversation.

When a guard submits a visit, the homeowner needs to receive a push notification, see who is at the gate, and accept or reject the visitor. In this step, you will install Expo Notifications for real-time alerts and build the two homeowner screens.

In this step, get ready to:
  • Set up push notifications with Expo Notifications.
  • Build the Visitor Alert screen with accept and reject functionality.
  • Build the Visit History screen and wire up the homeowner navigator.
Install and configure Expo Notifications

Push notifications let the backend alert homeowners the instant a guard submits a visit request. Expo Push Notifications handles delivery through Expo's servers for free, so you do not need to configure Firebase Cloud Messaging or Apple Push Notification Service directly.

  • Install the expo-notifications package by running this command in your GateGuard project folder:
npx expo install expo-notifications

What does this command do?

The npx expo install command installs the package at the version compatible with your current Expo SDK. It also registers the config plugin so native code is generated at build time.

  • Open app.json in the root of your GateGuard folder.
  • Find the existing plugins array.
  • Add the expo-notifications plugin entry after the expo-camera entry so the array looks like this:
"plugins": [
  ["expo-camera", {"cameraPermission": "Allow GateGuard to access your camera to scan visitor IDs."}],
  ["expo-notifications", {"icon": "./assets/notification-icon.png"}]
]

What does this config do?

The expo-notifications plugin entry tells Expo to set up native notification permissions and channel configuration when you build the app. The icon field points to the small icon shown in the Android notification tray.

  • Save app.json.
  • Open package.json and confirm that expo-notifications appears in the dependencies object. The install command should have added it automatically.

Don't see expo-notifications in package.json?

Make sure you ran the install command inside the GateGuard directory (not the parent folder or the backend folder). Run npx expo install expo-notifications again from inside GateGuard.

Ask for help: why isn't expo-notifications showing up in my package.json after running the install command?

Build the Visitor Alert screen

This is the screen homeowners see when a visitor arrives. It displays the visitor's photo, name, and ID number, then lets the homeowner accept or reject the visit.

  • Create a new folder at src/screens/homeowner/ inside your GateGuard project.
  • Create a new file called VisitorAlertScreen.tsx inside that folder.
  • Add the following code to VisitorAlertScreen.tsx:
import React, { useState } from 'react';
import { View, Text, Image, Button, TextInput } from 'react-native';
import { useAuth } from '../../hooks/useAuth';

export default function VisitorAlertScreen({ route }: any) {
  // Extract the visit object passed via navigation params
  const { visit } = route.params;
  const { token } = useAuth();
  const [rejectionReason, setRejectionReason] = useState('');
  const [showReject, setShowReject] = useState(false);

  // Send accept or reject response to the backend
  const respond = async (status: 'ACCEPTED' | 'REJECTED') => {
    await fetch(`http://localhost:8080/visits/${visit.id}/respond`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ status, rejectionReason })
    });
  };

  return (
    <View>
      {/* Visitor photo from the scanned ID */}
      <Image source={{ uri: visit.visitorImageUrl }} style={{ width: 100, height: 100 }} />
      <Text>{visit.visitorName}</Text>
      <Text>ID: {visit.idNumber}</Text>
      {/* Accept immediately sends ACCEPTED status */}
      <Button title="Accept" onPress={() => respond('ACCEPTED')} />
      {/* Reject reveals a reason input before confirming */}
      <Button title="Reject" onPress={() => setShowReject(true)} />
      {showReject && (
        <View>
          <TextInput value={rejectionReason} onChangeText={setRejectionReason} placeholder="Reason for rejection" />
          <Button title="Confirm Reject" onPress={() => respond('REJECTED')} />
        </View>
      )}
    </View>
  );
}

What does this code do?

  • The screen receives a visit object through navigation route params. This object contains the visitor's photo URL, name, and ID number.
  • The respond function calls POST /visits/{id}/respond on your Spring Boot API with either ACCEPTED or REJECTED as the status.
  • When the homeowner taps Reject, a TextInput appears so they can provide a reason before confirming.
  • Save VisitorAlertScreen.tsx.

Seeing a TypeScript error on route.params?

The any type annotation on the route prop suppresses strict type checking for now. If your editor still shows an error, make sure you imported View, Text, Image, Button, and TextInput from react-native.

Ask for help: why is TypeScript complaining about route.params in my VisitorAlertScreen?

Build the Visit History screen and wire up the navigator

Homeowners need a way to review past visits. This screen fetches the homeowner's visit log from the API and displays each entry with its status.

  • Create a new file called VisitHistoryScreen.tsx inside src/screens/homeowner/.
  • Add the following code to VisitHistoryScreen.tsx:
import React, { useEffect, useState } from 'react';
import { View, Text, FlatList } from 'react-native';
import { useAuth } from '../../hooks/useAuth';

export default function VisitHistoryScreen() {
  const { token } = useAuth();
  const [visits, setVisits] = useState([]);

  // Fetch the homeowner's visit history on mount
  useEffect(() => {
    fetch('http://localhost:8080/visits/my', { headers: { Authorization: `Bearer ${token}` } })
      .then(r => r.json()).then(setVisits);
  }, []);

  return (
    <FlatList
      data={visits}
      keyExtractor={i => i.id}
      renderItem={({ item }) => (
        <View>
          <Text>{item.visitorName} \u2014 {item.status}</Text>
          <Text>{item.visitedAt}</Text>
        </View>
      )}
    />
  );
}

What does this code do?

  • The useEffect hook calls GET /visits/my when the screen mounts. This endpoint returns visits associated with the logged-in homeowner.
  • Each visit displays the visitor's name, the response status (accepted, rejected, or pending), and the timestamp.
  • The FlatList component efficiently renders large lists by only mounting visible items.
  • Save VisitHistoryScreen.tsx.

Now you need to wire both screens into the HomeownerNavigator so that homeowners can reach them after logging in.

  • Open src/navigation/HomeownerNavigator.tsx.
  • Replace the entire file contents with the following code:
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import VisitorAlertScreen from '../screens/homeowner/VisitorAlertScreen';
import VisitHistoryScreen from '../screens/homeowner/VisitHistoryScreen';

const Stack = createStackNavigator();
export default function HomeownerNavigator() {
  return (
    // VisitHistory is the default landing screen for homeowners
    <Stack.Navigator initialRouteName="VisitHistory">
      <Stack.Screen name="VisitorAlert" component={VisitorAlertScreen} />
      <Stack.Screen name="VisitHistory" component={VisitHistoryScreen} />
    </Stack.Navigator>
  );
}

Why is VisitHistory the initial route?

Homeowners spend most of their time reviewing past visits. The Visit History screen is their home base. When a push notification arrives, the app navigates to VisitorAlertScreen with the visit data passed as route params.

  • Save HomeownerNavigator.tsx.
  • Run npx expo start from the GateGuard folder on your Android device. Log in as a user with the HOMEOWNER role. You should see the Visit History screen as the default view.

Seeing a blank screen after login?

Check that HomeownerNavigator.tsx imports from the correct relative paths (../screens/homeowner/VisitorAlertScreen and ../screens/homeowner/VisitHistoryScreen). A missing homeowner/ segment in the path causes a module-not-found error.

Ask for help: I see a blank screen when I log in as a homeowner. How do I debug the navigator?

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: VisitorAlertScreen.tsx, VisitHistoryScreen.tsx, and HomeownerNavigator.tsx.

ⓧ I'd like to double check the full code

Here is the full code for each file you created or modified in this step.

{
  "expo": {
    "name": "GateGuard",
    "slug": "gateguard",
    "version": "1.0.0",
    "orientation": "portrait",
    "platforms": ["ios", "android"],
    "sdkVersion": "56.0.0",
    "scheme": "gateguard",
    "plugins": [
      ["expo-camera", {"cameraPermission": "Allow GateGuard to access your camera to scan visitor IDs."}],
      ["expo-notifications", {"icon": "./assets/notification-icon.png"}]
    ]
  }
}
import React, { useState } from 'react';
import { View, Text, Image, Button, TextInput } from 'react-native';
import { useAuth } from '../../hooks/useAuth';

export default function VisitorAlertScreen({ route }: any) {
  const { visit } = route.params;
  const { token } = useAuth();
  const [rejectionReason, setRejectionReason] = useState('');
  const [showReject, setShowReject] = useState(false);

  const respond = async (status: 'ACCEPTED' | 'REJECTED') => {
    await fetch(`http://localhost:8080/visits/${visit.id}/respond`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ status, rejectionReason })
    });
  };

  return (
    <View>
      <Image source={{ uri: visit.visitorImageUrl }} style={{ width: 100, height: 100 }} />
      <Text>{visit.visitorName}</Text>
      <Text>ID: {visit.idNumber}</Text>
      <Button title="Accept" onPress={() => respond('ACCEPTED')} />
      <Button title="Reject" onPress={() => setShowReject(true)} />
      {showReject && (
        <View>
          <TextInput value={rejectionReason} onChangeText={setRejectionReason} placeholder="Reason for rejection" />
          <Button title="Confirm Reject" onPress={() => respond('REJECTED')} />
        </View>
      )}
    </View>
  );
}
import React, { useEffect, useState } from 'react';
import { View, Text, FlatList } from 'react-native';
import { useAuth } from '../../hooks/useAuth';

export default function VisitHistoryScreen() {
  const { token } = useAuth();
  const [visits, setVisits] = useState([]);

  useEffect(() => {
    fetch('http://localhost:8080/visits/my', { headers: { Authorization: `Bearer ${token}` } })
      .then(r => r.json()).then(setVisits);
  }, []);

  return (
    <FlatList
      data={visits}
      keyExtractor={i => i.id}
      renderItem={({ item }) => (
        <View>
          <Text>{item.visitorName} \u2014 {item.status}</Text>
          <Text>{item.visitedAt}</Text>
        </View>
      )}
    />
  );
}
import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import VisitorAlertScreen from '../screens/homeowner/VisitorAlertScreen';
import VisitHistoryScreen from '../screens/homeowner/VisitHistoryScreen';

const Stack = createStackNavigator();
export default function HomeownerNavigator() {
  return (
    <Stack.Navigator initialRouteName="VisitHistory">
      <Stack.Screen name="VisitorAlert" component={VisitorAlertScreen} />
      <Stack.Screen name="VisitHistory" component={VisitHistoryScreen} />
    </Stack.Navigator>
  );
}

Your homeowner experience is complete. When a guard submits a visit, the homeowner will receive a push notification that navigates them to the Visitor Alert screen. Next up, you will set up the AWS infrastructure to run this entire system in the cloud.

Set Up AWS Infrastructure

Your GateGuard app is running beautifully on your local machine. Guards can scan IDs, homeowners can respond to visits, and push notifications keep everyone in the loop.

Now it is time to move everything to the cloud. In this step, you will prepare your AWS account to host GateGuard by earning credits, setting up billing protection, and creating the network infrastructure your servers will live inside.

In this step, get ready to:
  • Complete all 5 AWS onboarding tasks to earn $200 in credits.
  • Set up billing alerts so you never get surprised by charges.
  • Create a VPC, security groups, and an IAM role for your application.
Complete AWS onboarding tasks to earn credits

AWS Free Plan accounts created after July 2025 receive $100 in credits immediately. You can earn up to $100 more by completing 5 onboarding tasks, bringing your total to $200.

These credits fund all the infrastructure GateGuard needs. Let's complete each task now.

  • Sign in to the AWS Management Console at https://console.aws.amazon.com.
  • Make sure your region is set to US East (N. Virginia) in the top-right corner of the console. This is the us-east-1 region.
  • Look for the onboarding checklist on the console home page. AWS shows your progress toward the 5 tasks.

What are the 5 onboarding tasks?

  • Launch and terminate an EC2 instance.
  • Launch an RDS database.
  • Create a Lambda function with a Function URL.
  • Submit a text prompt in the Bedrock playground.
  • Set up a cost budget with AWS Budgets.

Each task has a guided walkthrough inside the AWS console. Follow the prompts for each one. You can terminate or delete the temporary resources after each task completes. The credits are awarded once AWS registers that you performed the action.

  • Complete all 5 onboarding tasks. AWS will mark each as done on the checklist.
  • After completing all tasks, verify your credits by navigating to Billing and Cost Management in the search bar and checking your Credits section. You should see up to $200 in available credits.

Credits take time to appear

AWS credits can take up to 24 hours to show in your account after completing the onboarding tasks. If you do not see the full amount immediately, continue with the next substep and check back later.

Can't find the onboarding checklist?

The onboarding tasks appear on the console home page for Free Plan accounts. If you do not see them, search for each service (EC2, RDS, Lambda, Bedrock, Budgets) individually and complete the equivalent action. The credits are tied to the action, not the checklist UI.

Help me find the AWS onboarding tasks for earning credits

Set up AWS Budgets and CloudWatch billing alarms

Before you deploy anything, you need billing guardrails. You will create an AWS Budget to track your overall spend, and CloudWatch billing alarms that email you at low thresholds so you can react quickly.

Create the AWS Budget
  • Search for Budgets in the AWS console search bar and select AWS Budgets.
  • Click Create budget.
  • Under Budget setup, choose Customize (advanced).
  • Under Budget types, choose Cost budget, then click Next.
  • For Budget name, enter GateGuard-Budget.
  • For Period, choose Monthly.
  • For Budget renewal type, choose Recurring budget.
  • For Budgeting method, choose Fixed.
  • Enter 200 as the budget amount (matching your total credits).
  • Click Next.

Now add four alert thresholds:

  • Click Add an alert threshold.
  • Enter 5 as the threshold, choose Absolute value, and select Actual spend.
  • Under Email recipients, enter your email address.
  • Repeat for thresholds of 10, 50, and 100 (adding a new alert threshold each time with the same email).
  • Click Next, review the settings, then click Create budget.

Why four thresholds?

The $5 and $10 alerts catch unexpected charges early when you might have left an instance running overnight. The $50 and $100 alerts let you know you are approaching meaningful portions of your $200 credit pool.

Create CloudWatch billing alarms

CloudWatch billing alarms provide a second layer of protection. Billing metric data is stored in US East (N. Virginia), so you must create these alarms in that region.

  • Search for CloudWatch in the console search bar and open it.
  • Confirm your region is set to US East (N. Virginia) in the top-right corner.
  • In the left navigation, click Alarms, then All alarms.
  • Click Create alarm.
  • Click Select metric. Under the Browse tab, choose Billing, then Total Estimated Charge.
  • Select the checkbox for EstimatedCharges, then click Select metric.
  • Set Statistic to Maximum and Period to 6 hours.
  • Under Conditions, set Threshold type to Static.
  • For Whenever EstimatedCharges is..., choose Greater, and enter 5 for the threshold value.
  • Click Next.
  • Under Notification, ensure In alarm is selected. Create a new SNS topic and enter your email address to receive notifications.
  • Click Next.
  • For the alarm name, enter BillingAlarm-5.
  • Click Next, review the settings, then click Create alarm.
  • Repeat the exact same process to create a second alarm named BillingAlarm-10 with a threshold of 10 USD.
  • Check your email inbox for a confirmation from Amazon SNS with the subject line containing "Subscription Confirmation". Click Confirm subscription to activate notifications.

Don't see the Billing metric?

If you do not see the Billing or Total Estimated Charge option, you need to enable billing alerts first. Go to the Billing and Cost Management console, choose Billing Preferences in the navigation pane, find Alert preferences, click Edit, enable Receive CloudWatch Billing Alerts, and save. Return to CloudWatch after a few minutes.

Help me enable CloudWatch billing alerts on my AWS account

Create VPC, security groups, and IAM roles

Your application needs a private network on AWS. A VPC (Virtual Private Cloud) isolates your resources and controls what traffic can reach them. You will create one VPC with a public subnet (for your EC2 servers) and a private subnet (for your RDS database).

Create the VPC
  • Search for VPC in the console search bar and open the VPC dashboard.
  • Click Create VPC.
  • For Resources to create, choose VPC and more.
  • For Name tag auto-generation, enter gateguard. AWS will name the VPC gateguard-vpc automatically.
  • For IPv4 CIDR block, enter 10.0.0.0/16.
  • For Number of Availability Zones, choose 2.
  • Expand Customize AZs and select us-east-1a and us-east-1b.
  • For Number of public subnets, choose 1.
  • For Number of private subnets, choose 1.
  • Expand Customize subnet CIDR blocks and set the public subnet to 10.0.1.0/24 (us-east-1a) and the private subnet to 10.0.2.0/24 (us-east-1b).
  • For NAT gateways, choose None (saves cost since your private subnet only needs inbound from EC2).
  • For VPC endpoints, choose None.
  • Click Create VPC. Wait for the creation to complete.

What is a CIDR block?

A CIDR block defines a range of IP addresses for your network. 10.0.0.0/16 gives you 65,536 addresses. The public subnet (10.0.1.0/24) holds EC2 instances that face the internet. The private subnet (10.0.2.0/24) holds your database where only your EC2 instances can reach it.

Create security groups

Security groups act as firewalls for individual resources. You need two: one for your EC2 instances and one for your RDS database.

  • In the VPC console left navigation, click Security Groups.
  • Click Create security group.
  • For Security group name, enter gateguard-ec2-sg.
  • For Description, enter Security group for GateGuard EC2 instances.
  • For VPC, select the gateguard-vpc you just created.

Add three inbound rules:

  • Click Add rule. Set Type to SSH, Port 22, Source 0.0.0.0/0.
  • Click Add rule. Set Type to Custom TCP, Port 8080, Source 0.0.0.0/0. This is for Spring Boot.
  • Click Add rule. Set Type to Custom TCP, Port 8180, Source 0.0.0.0/0. This is for Keycloak.
  • Click Create security group.

Now create the RDS security group:

  • Click Create security group again.
  • For Security group name, enter gateguard-rds-sg.
  • For Description, enter Security group for GateGuard RDS database.
  • For VPC, select gateguard-vpc.
  • Click Add rule. Set Type to PostgreSQL, Port 5432. For Source, search for and select gateguard-ec2-sg. This means only your EC2 instances can talk to the database.
  • Click Create security group.

Why restrict RDS access to the EC2 security group?

By setting the source of the RDS inbound rule to gateguard-ec2-sg instead of 0.0.0.0/0, your database is never directly accessible from the internet. Only resources tagged with the EC2 security group can connect. This is a fundamental security best practice for production databases.

Create the IAM role

Your EC2 instances need permission to upload images to S3 and send emails through SES. An IAM role grants these permissions without embedding credentials on the server.

  • Search for IAM in the console search bar and open the IAM dashboard.
  • In the left navigation, click Roles.
  • Click Create role.
  • For Trusted entity type, choose AWS service.
  • For Use case, select EC2.
  • Click Next.
  • In the permissions search box, search for AmazonS3FullAccess and check its box.
  • Search for AmazonSESFullAccess and check its box as well.
  • Click Next.
  • For Role name, enter gateguard-ec2-role.
  • Review the trust policy (it should show EC2 as the trusted entity) and the two attached policies.
  • Click Create role.

What does an IAM role do for EC2?

When you attach this role to an EC2 instance, any application running on that instance can call S3 and SES without needing access keys stored in environment variables. AWS handles the credentials automatically through instance metadata. This is more secure than hardcoding keys.

Verify your infrastructure is complete:

  • Navigate back to the VPC console and confirm gateguard-vpc shows Available status with 2 subnets listed.
  • Under Security Groups, confirm both gateguard-ec2-sg and gateguard-rds-sg are present.
  • In the IAM console under Roles, confirm gateguard-ec2-role exists with 2 policies attached.

VPC creation failed?

If VPC creation shows an error, check that you have not exceeded the VPC limit for your region (default is 5 VPCs per region). You can delete any unused default VPCs in other regions or request a limit increase through the AWS Service Quotas console.

Help me troubleshoot my VPC creation error in AWS

Your AWS infrastructure is locked and loaded. You have billing protection in place, a secure network ready, and permissions configured. Next up, you will deploy Keycloak, Spring Boot, and RDS into this infrastructure.

Deploy to AWS

In the previous step, you set up your AWS infrastructure with a VPC, security groups, IAM roles, and billing protection. Now it's time to deploy your entire backend stack to the cloud.

You'll launch a managed PostgreSQL database, spin up two EC2 instances (one for Keycloak, one for your Spring Boot API), and configure S3 and SES for image storage and email notifications. By the end of this step, your mobile app will talk to a live cloud backend instead of localhost.

In this step, get ready to:
  • Launch an RDS PostgreSQL database and run Liquibase migrations against it.
  • Deploy Keycloak to an EC2 instance using Docker.
  • Deploy the Spring Boot API to a second EC2 instance with S3 and SES configured.
Launch RDS PostgreSQL and run Liquibase migrations

Your application needs a database that's accessible from both the Keycloak and Spring Boot EC2 instances. RDS handles backups, patching, and availability so you can focus on your app code.

  • In the AWS Management Console, search for RDS and open the RDS dashboard.
  • Click Create database.
  • Select Standard create.
  • Under Engine options, choose PostgreSQL.
  • For the engine version, select PostgreSQL 15.
  • Under Templates, select Free tier.
  • In Settings, enter gateguard-db as the DB instance identifier.
  • Set the Master username to postgres.
  • Enter and confirm a strong master password. Save this password somewhere safe.
  • Under Instance configuration, confirm db.t3.micro is selected.
  • Under Storage, set the allocated storage to 20 GB with gp2 storage type.
  • Under Availability & durability, confirm Single-AZ is selected.
  • Under Connectivity, select the gateguard-vpc VPC.
  • Create a new DB subnet group that includes both the 10.0.1.0/24 and 10.0.2.0/24 subnets. AWS requires subnets in at least two Availability Zones for a DB subnet group.
  • Set Public access to No.
  • Under VPC security group, choose Choose existing.
  • Select gateguard-rds-sg.
  • Under Additional configuration, enter gateguard as the Initial database name.
  • Click Create database.

Why a private subnet for the database?

Placing RDS in a private subnet with public access disabled means the database has no public IP address. Only resources inside the VPC (your EC2 instances) can reach it on port 5432.

This is a fundamental security best practice for production databases. It prevents any external traffic from even attempting a connection.

  • Wait for the database status to show Available. This takes 5-10 minutes.
  • Once available, click on gateguard-db and copy the Endpoint value from the Connectivity & security tab. Save this for later.

When do the Liquibase migrations run?

Your Spring Boot application includes spring-boot-starter-liquibase and has the changelog configured in application.yml. When the Spring Boot API starts and connects to the RDS database, it will automatically apply all 6 changesets (users, subdivisions, addresses, visitors, visit_logs, notifications).

You'll verify the migrations ran successfully after deploying the API in the final substep.

Database stuck in Creating state for over 15 minutes?

RDS creation can occasionally take longer if the region is under heavy load. Check the Events tab on the RDS dashboard for any error messages.

If you see a subnet group error, ensure both subnets in your group are in different Availability Zones (us-east-1a and us-east-1b).

help me troubleshoot my RDS PostgreSQL instance creation

Deploy Keycloak to EC2 with Docker

Keycloak needs about 1.2 GB of memory at baseline, so it runs on a t3.small instance (2 GB RAM). You'll install Docker on the instance and run the official Keycloak container image connected to your RDS database.

  • Return to the EC2 dashboard in the AWS Console.
  • Click Launch instances.
  • Enter gateguard-keycloak as the instance name.
  • Under Application and OS Images, select Amazon Linux 2023 AMI.
  • Under Instance type, select t3.small.
  • Under Key pair, create a new key pair or select an existing one.
  • Download the .pem file and save it securely.
  • Under Network settings, click Edit.
  • Select the gateguard-vpc VPC.
  • Select the public subnet (10.0.1.0/24).
  • Enable Auto-assign public IP.
  • Under Firewall, select Select existing security group.
  • Choose gateguard-ec2-sg.
  • Expand Advanced details at the bottom.
  • Select gateguard-ec2-role for the IAM instance profile.
  • Click Launch instance.

Wait for the instance state to show Running, then note the public IP address from the instance details.

  • SSH into your new instance by running this command:
ssh -i [[PEMPATH="path to your .pem file"]] ec2-user@[[KEYCLOAK_EC2_IP="your Keycloak EC2 public IP"]]
  • Install Docker by running these commands:
# Update packages
sudo yum update -y

# Install Docker
sudo yum install -y docker

# Start Docker service
sudo service docker start

# Add ec2-user to docker group so you can run without sudo
sudo usermod -a -G docker ec2-user

What do these commands do?

The first two commands update the system packages and install Docker from the Amazon Linux extras repository.

Starting the service makes Docker available immediately. Adding ec2-user to the docker group lets you run Docker commands without sudo after you re-login.

  • Log out of the SSH session by typing exit.
  • SSH back in so the docker group permissions take effect.
  • Verify Docker is working by running:
docker ps

You should see an empty container list with column headers. No errors means Docker is ready.

Permission denied when running docker ps?

Make sure you logged out and back in after running the usermod command. The group change only takes effect in a new session.

help me fix Docker permissions on my EC2 instance

Now start Keycloak connected to your RDS database. First, export your local Keycloak realm so you can import it on EC2.

  • On your local machine, open a new terminal and export the GateGuard realm from your local Keycloak instance.
  • Copy the exported realm JSON file to the EC2 instance by running:
scp -i [[PEMPATH="path to your .pem file"]] gateguard-realm.json ec2-user@[[KEYCLOAK_EC2_IP="your Keycloak EC2 public IP"]]:/home/ec2-user/
  • Back on the EC2 instance, run Keycloak in Docker connected to your RDS database:
docker run -d --name keycloak \
  -p 8180:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=your-keycloak-admin-password-here \
  -e KC_DB=postgres \
  -e KC_DB_URL=jdbc:postgresql://your-rds-endpoint-here:5432/gateguard \
  -e KC_DB_USERNAME=postgres \
  -e KC_DB_PASSWORD=your-rds-password-here \
  quay.io/keycloak/keycloak:26.6.3 \
  start-dev

What does this command do?

This runs the official Keycloak 26.6.3 container in detached mode. It maps the container's port 8080 to the host's port 8180, which matches your security group rule.

The KC_DB environment variables tell Keycloak to use your RDS PostgreSQL instance instead of its embedded H2 database. The start-dev command skips hostname and TLS requirements for development.

  • Verify Keycloak is running by checking the container logs:
docker logs keycloak

You should see Keycloak startup logs ending with a message that the server is listening. This may take 30-60 seconds on a t3.small instance.

  • Open a browser and navigate to your Keycloak EC2 public IP on port 8180 to confirm the Keycloak login page loads.
  • Log in with the admin credentials you set in the Docker command.
  • Click Create realm in the top-left dropdown.
  • Click Browse and upload the GateGuard realm JSON file you copied earlier.
  • Click Create to import the realm with all roles and client configurations.

Keycloak page not loading in the browser?

Check that your security group gateguard-ec2-sg allows inbound TCP on port 8180. Also confirm the instance has a public IP and is in the public subnet.

Run docker logs keycloak to check for database connection errors. If Keycloak can't reach RDS, verify that gateguard-rds-sg allows inbound from gateguard-ec2-sg on port 5432.

help me troubleshoot my Keycloak Docker container on EC2

Deploy Spring Boot API to EC2 with S3 and SES

Your Spring Boot API runs on a separate t3.micro instance. It needs Java 21, your application JAR, and environment variables pointing to RDS and Keycloak.

  • Return to the EC2 dashboard and click Launch instances.
  • Enter gateguard-api as the instance name.
  • Select Amazon Linux 2023 AMI.
  • Under Instance type, select t3.micro.
  • Select the same key pair you used for the Keycloak instance.
  • Under Network settings, click Edit.
  • Select the gateguard-vpc VPC.
  • Select the public subnet (10.0.1.0/24).
  • Enable Auto-assign public IP.
  • Under Firewall, select Select existing security group.
  • Choose gateguard-ec2-sg.
  • Expand Advanced details and select gateguard-ec2-role for the IAM instance profile.
  • Click Launch instance.

Wait for the instance state to show Running, then note the public IP address.

  • SSH into the new instance by running:
ssh -i [[PEMPATH="path to your .pem file"]] ec2-user@[[API_EC2_IP="your API EC2 public IP"]]
  • Install Java Temurin 21 by first adding the Adoptium repository:
# Add the Adoptium RPM repository
cat <<EOF | sudo tee /etc/yum.repos.d/adoptium.repo
[Adoptium]
name=Adoptium
baseurl=https://packages.adoptium.net/artifactory/rpm/centos/8/$(uname -m)
enabled=1
gpgcheck=1
gpgkey=https://packages.adoptium.net/artifactory/api/gpg/key/public
EOF

# Install Temurin 21 JDK
sudo dnf install -y temurin-21-jdk

What does this command do?

This adds the Adoptium package repository to your system. Adoptium hosts Eclipse Temurin builds of OpenJDK.

Then sudo dnf install -y temurin-21-jdk installs the full Java 21 JDK. Amazon Linux 2023 uses dnf as its package manager.

  • Verify Java is installed by running:
java -version

You should see output showing OpenJDK version 21 from Eclipse Adoptium.

Now upload and run your Spring Boot application JAR.

  • On your local machine, open a new terminal in your Spring Boot project directory.
  • Build the production JAR by running:
./gradlew bootJar
  • Copy the JAR to your EC2 instance by running:
scp -i [[PEMPATH="path to your .pem file"]] build/libs/gateguard-api.jar ec2-user@[[API_EC2_IP="your API EC2 public IP"]]:/home/ec2-user/
  • Back on the EC2 instance, start the Spring Boot application with environment variables pointing to your cloud resources:
# Start Spring Boot with cloud configuration
SPRING_DATASOURCE_URL=jdbc:postgresql://your-rds-endpoint-here:5432/gateguard \
SPRING_DATASOURCE_USERNAME=postgres \
SPRING_DATASOURCE_PASSWORD=your-rds-password-here \
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI=http://your-keycloak-ec2-ip:8180/realms/gateguard \
java -jar gateguard-api.jar &

What do these environment variables do?

SPRING_DATASOURCE_URL tells Spring Boot to connect to your RDS PostgreSQL instance instead of localhost. When the app starts, Liquibase automatically detects the new empty database and applies all 6 migration changesets.

SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI points to your Keycloak EC2 instance so Spring Security can validate JWT tokens issued by Keycloak. The /realms/gateguard path matches your realm name.

  • Confirm Spring Boot started successfully by checking the logs. You should see Liquibase reporting 6 changesets applied and the application listening on port 8080.

Spring Boot failing to start?

If you see a database connection error, double-check your RDS endpoint in the SPRING_DATASOURCE_URL value. Make sure gateguard-rds-sg allows inbound from gateguard-ec2-sg on port 5432.

If you see a JWT issuer error, confirm that Keycloak is reachable at the URL you provided. Test with curl http://your-keycloak-ec2-ip:8180/realms/gateguard from the API instance.

help me debug my Spring Boot application failing to start on EC2

Now create the S3 bucket for visitor ID image storage.

  • In the AWS Console, search for S3 and open the S3 dashboard.
  • Click Create bucket.
  • Enter gateguard-images as the bucket name. If this name is taken, append a unique suffix like your initials.
  • Select us-east-1 as the region.
  • Keep Block all public access enabled.
  • Leave versioning disabled.
  • Click Create bucket.

Why block public access on S3?

Visitor ID images contain sensitive personal information. Your Spring Boot API accesses them through the gateguard-ec2-role IAM role (which has AmazonS3FullAccess), so public access is never needed.

Finally, configure SES for email notifications to homeowners.

  • In the AWS Console, search for SES and open the Amazon SES dashboard.
  • Click Verified identities in the left sidebar.
  • Click Create identity.
  • Select Email address as the identity type.
  • Enter the email address you want to send notifications from.
  • Click Create identity.
  • Check your email inbox and click the verification link from AWS.

What is SES sandbox mode?

New SES accounts start in sandbox mode, which means you can only send emails to verified email addresses. For development and testing, this is fine.

You can verify the homeowner email addresses you'll use for testing. For production use, you'd request to move out of sandbox mode through the AWS Console.

The last piece is to update your mobile app to point to the live API.

  • In your GateGuard React Native project, update the API base URL to point to your gateguard-api EC2 instance's public IP on port 8080.
  • Test the connection by running your app and attempting to log in through Keycloak.

App can't reach the API?

Confirm port 8080 is open in gateguard-ec2-sg (it should be from the previous step). Check that the Spring Boot process is still running on EC2.

If using HTTPS on the mobile app but HTTP on the server, you may need to configure network security exceptions in your Expo config for development.

help me debug my mobile app connection to the Spring Boot EC2 instance

Your entire backend is now live in the cloud. Your mobile app talks to the Spring Boot API on EC2, which validates tokens against Keycloak on another EC2, and stores data in RDS PostgreSQL. Next up, you'll build the React web admin dashboard for system management.

Build the React Web Admin Dashboard

Your full-stack system is deployed and your mobile app is talking to production. But right now, managing subdivisions, users, and visit logs requires direct database queries or API calls.

In this step, you will build a React web admin dashboard that lets system administrators manage the entire GateGuard system from a browser. The dashboard authenticates through the same Keycloak instance your mobile app and API use, and restricts access to users with the ADMIN role.

In this step, get ready to:
  • Create a React web app with Vite and TypeScript.
  • Connect the admin dashboard to Keycloak for authentication.
  • Build admin screens for subdivisions, users, visit logs, and system config.
Create a React (Vite) web app with TypeScript

The admin dashboard is a standalone web application, separate from your React Native mobile app. You will scaffold it using Vite, a fast frontend build tool that provides instant hot module replacement during development.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the admin dashboard project by running this command:
npm create vite@latest gateguard-admin -- --template react-ts

What does this command do?

This uses the create-vite scaffolder to generate a new React project with TypeScript pre-configured. The --template react-ts flag selects the React + TypeScript starter template.

  • Move into the new project folder by running:
  • Install the base dependencies by running:
cd gateguard-admin
npm install
  • Install the Keycloak and routing packages your admin dashboard needs by running:
npm install keycloak-js @react-keycloak/web react-router-dom

What are these packages?

The keycloak-js package is the official JavaScript adapter for communicating with a Keycloak server. @react-keycloak/web provides React bindings (a provider and hook) so your components can access the Keycloak authentication state. react-router-dom handles client-side page routing.

  • Confirm the project runs by starting the dev server:
npm run dev

You should see output indicating the Vite dev server is running, typically at http://localhost:5173.

  • Open http://localhost:5173 in your browser to verify the default Vite + React page loads.
  • Press Ctrl+C in the terminal to stop the dev server for now.

Dev server not starting?

Make sure you are inside the gateguard-admin folder before running npm run dev. If you see a port conflict, another process may already be using port 5173. Stop that process or Vite will automatically try the next available port.

help me fix my Vite dev server startup issue

Implement Keycloak authentication for the admin dashboard

Your admin dashboard must only be accessible to users with the ADMIN role. You will create a new OIDC client in Keycloak for the web dashboard, then wire it into your React app so that unauthenticated users are redirected to the Keycloak login page.

  • Open the Keycloak Admin Console in your browser by navigating to your Keycloak EC2 instance at port 8180.
  • Sign in with your admin credentials.
  • Select the gateguard realm from the dropdown.
  • Click Clients in the left sidebar.
  • Click Create client.
  • Enter gateguard-admin as the Client ID.
  • Keep Client type set to OpenID Connect.
  • Click Next.
  • Enable Standard flow (should be on by default).
  • Click Next.
  • In the Valid redirect URIs field, enter http://localhost:5173/*.
  • Click Save.

Why a separate client for the admin dashboard?

Each application that authenticates through Keycloak needs its own OIDC client. This keeps redirect URIs, session lifetimes, and access policies isolated. Your mobile app uses the gateguard-mobile client. The admin dashboard uses gateguard-admin.

Now create the Keycloak configuration file in your React project.

  • In your code editor, create a new file at gateguard-admin/src/keycloak.ts and add the following code:
import Keycloak from 'keycloak-js';

// Configure the Keycloak client to connect to your GateGuard realm
const keycloak = new Keycloak({
  url: 'http://[[KEYCLOAK_EC2_IP="your Keycloak EC2 public IP"]]:8180',
  realm: 'gateguard',
  clientId: 'gateguard-admin',
});

export default keycloak;

What does this code do?

This creates a Keycloak instance configured with your server's URL, the gateguard realm name, and the gateguard-admin client ID you just registered. When the app loads, this instance handles the entire OAuth2 redirect flow.

  • Create the main App.tsx file that wraps the entire app in the Keycloak provider. Replace the contents of gateguard-admin/src/App.tsx with:
import React from 'react';
import { ReactKeycloakProvider } from '@react-keycloak/web';
import keycloak from './keycloak';
import AdminRouter from './AdminRouter';

// Wrap the app in the Keycloak auth provider
export default function App() {
  return (
    <ReactKeycloakProvider authClient={keycloak}>
      <AdminRouter />
    </ReactKeycloakProvider>
  );
}

What does ReactKeycloakProvider do?

The ReactKeycloakProvider automatically initializes keycloak.init() when the app loads. It provides the Keycloak instance to all child components via React context. Any component can then call useKeycloak() to check authentication state, get tokens, or trigger login/logout.

  • Create the router file at gateguard-admin/src/AdminRouter.tsx with the following code:
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { useKeycloak } from '@react-keycloak/web';
import ManageSubdivisionsPage from './pages/ManageSubdivisionsPage';
import ManageUsersPage from './pages/ManageUsersPage';
import VisitLogsPage from './pages/VisitLogsPage';
import SystemConfigPage from './pages/SystemConfigPage';

// Gate all routes behind Keycloak authentication
export default function AdminRouter() {
  const { keycloak } = useKeycloak();
  if (!keycloak.authenticated) return <button onClick={() => keycloak.login()}>Login</button>;
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/subdivisions" element={<ManageSubdivisionsPage />} />
        <Route path="/users" element={<ManageUsersPage />} />
        <Route path="/visit-logs" element={<VisitLogsPage />} />
        <Route path="/config" element={<SystemConfigPage />} />
      </Routes>
    </BrowserRouter>
  );
}

What does this code do?

The AdminRouter component checks if the user is authenticated via Keycloak. If not, it renders a login button that triggers the Keycloak login redirect. Once authenticated, it renders the React Router routes for each admin page.

  • Save all three files: keycloak.ts, App.tsx, and AdminRouter.tsx.

Seeing a compile error about missing page imports?

This is expected. The AdminRouter.tsx imports page components that do not exist yet. You will create them in the next substep.

Build admin screens for subdivisions, users, visit logs, and system config

Each admin screen is a React component that communicates with your Spring Boot API. You will create four pages that cover the core administration workflows.

  • Create a pages folder inside gateguard-admin/src/ by running:
mkdir src/pages
  • Create the subdivisions management page at src/pages/ManageSubdivisionsPage.tsx:
import React, { useEffect, useState } from 'react';
import { useKeycloak } from '@react-keycloak/web';

// Admin page for managing subdivisions (CRUD)
export default function ManageSubdivisionsPage() {
  const { keycloak } = useKeycloak();
  const [subdivisions, setSubdivisions] = useState<any[]>([]);
  const [name, setName] = useState('');

  // Fetch all subdivisions from the API
  useEffect(() => {
    fetch('/api/subdivisions', {
      headers: { Authorization: `Bearer ${keycloak.token}` },
    })
      .then((res) => res.json())
      .then(setSubdivisions)
      .catch(console.error);
  }, [keycloak.token]);

  // Create a new subdivision
  const handleCreate = () => {
    fetch('/api/subdivisions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${keycloak.token}`,
      },
      body: JSON.stringify({ name }),
    })
      .then((res) => res.json())
      .then((newSub) => setSubdivisions([...subdivisions, newSub]))
      .catch(console.error);
  };

  return (
    <div>
      <h1>Manage Subdivisions</h1>
      <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Subdivision name" />
      <button onClick={handleCreate}>Add Subdivision</button>
      <ul>
        {subdivisions.map((s) => (
          <li key={s.id}>{s.name}</li>
        ))}
      </ul>
    </div>
  );
}

What does this page do?

This page fetches all subdivisions from your Spring Boot API using the Keycloak JWT token for authorization. It also provides a form to create new subdivisions. The token is passed in the Authorization header so Spring Security can validate the request.

  • Create the users management page at src/pages/ManageUsersPage.tsx:
import React, { useEffect, useState } from 'react';
import { useKeycloak } from '@react-keycloak/web';

// Admin page for listing users and assigning roles
export default function ManageUsersPage() {
  const { keycloak } = useKeycloak();
  const [users, setUsers] = useState<any[]>([]);

  // Fetch all users from the API
  useEffect(() => {
    fetch('/api/users', {
      headers: { Authorization: `Bearer ${keycloak.token}` },
    })
      .then((res) => res.json())
      .then(setUsers)
      .catch(console.error);
  }, [keycloak.token]);

  // Assign a role to a user
  const assignRole = (userId: string, role: string) => {
    fetch(`/api/users/${userId}/roles`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${keycloak.token}`,
      },
      body: JSON.stringify({ role }),
    }).catch(console.error);
  };

  return (
    <div>
      <h1>Manage Users</h1>
      <table>
        <thead>
          <tr><th>Name</th><th>Email</th><th>Role</th><th>Actions</th></tr>
        </thead>
        <tbody>
          {users.map((u) => (
            <tr key={u.id}>
              <td>{u.name}</td>
              <td>{u.email}</td>
              <td>{u.role}</td>
              <td>
                <button onClick={() => assignRole(u.id, 'GUARD')}>Set Guard</button>
                <button onClick={() => assignRole(u.id, 'HOMEOWNER')}>Set Homeowner</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
  • Create the visit logs page at src/pages/VisitLogsPage.tsx:
import React, { useEffect, useState } from 'react';
import { useKeycloak } from '@react-keycloak/web';

// Admin page for viewing all visit logs with filters
export default function VisitLogsPage() {
  const { keycloak } = useKeycloak();
  const [logs, setLogs] = useState<any[]>([]);
  const [statusFilter, setStatusFilter] = useState('');

  // Fetch visit logs from the API with optional status filter
  useEffect(() => {
    const query = statusFilter ? `?status=${statusFilter}` : '';
    fetch(`/api/visit-logs${query}`, {
      headers: { Authorization: `Bearer ${keycloak.token}` },
    })
      .then((res) => res.json())
      .then(setLogs)
      .catch(console.error);
  }, [keycloak.token, statusFilter]);

  return (
    <div>
      <h1>Visit Logs</h1>
      <select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
        <option value="">All</option>
        <option value="ACCEPTED">Accepted</option>
        <option value="REJECTED">Rejected</option>
        <option value="PENDING">Pending</option>
      </select>
      <table>
        <thead>
          <tr><th>Visitor</th><th>Homeowner</th><th>Status</th><th>Date</th></tr>
        </thead>
        <tbody>
          {logs.map((log) => (
            <tr key={log.id}>
              <td>{log.visitorName}</td>
              <td>{log.homeownerName}</td>
              <td>{log.status}</td>
              <td>{log.createdAt}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
  • Create the system config page at src/pages/SystemConfigPage.tsx:
import React, { useEffect, useState } from 'react';
import { useKeycloak } from '@react-keycloak/web';

// Admin page for viewing and updating system settings
export default function SystemConfigPage() {
  const { keycloak } = useKeycloak();
  const [config, setConfig] = useState<any>({});

  // Fetch current system configuration
  useEffect(() => {
    fetch('/api/config', {
      headers: { Authorization: `Bearer ${keycloak.token}` },
    })
      .then((res) => res.json())
      .then(setConfig)
      .catch(console.error);
  }, [keycloak.token]);

  // Save updated configuration
  const handleSave = () => {
    fetch('/api/config', {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${keycloak.token}`,
      },
      body: JSON.stringify(config),
    }).catch(console.error);
  };

  return (
    <div>
      <h1>System Configuration</h1>
      <label>
        Max visitors per day:
        <input
          type="number"
          value={config.maxVisitorsPerDay || ''}
          onChange={(e) => setConfig({ ...config, maxVisitorsPerDay: e.target.value })}
        />
      </label>
      <button onClick={handleSave}>Save Configuration</button>
    </div>
  );
}

What do these admin pages share in common?

All four pages follow the same pattern: they pull the Keycloak token from the useKeycloak() hook and pass it as a Bearer token in every API request. This ensures Spring Security validates each call against your Keycloak server before returning data.

  • Save all four page files.
  • Start the dev server again by running:
npm run dev
  • Open http://localhost:5173 in your browser. You should see a Login button (since you are not yet authenticated through Keycloak).
  • Click Login. You should be redirected to the Keycloak login page for the gateguard realm.
  • Sign in with an ADMIN user. After login, you are redirected back to the dashboard.

Redirect loop or invalid redirect URI error?

Double-check that the Valid redirect URIs field in your Keycloak gateguard-admin client is set to http://localhost:5173/*. The trailing /* wildcard is required for all sub-paths. Also verify that the url in your keycloak.ts file matches your Keycloak EC2 public IP and port exactly.

help me fix my Keycloak redirect URI error in the admin dashboard

✔️ Awesome, I've got everything!

Great. Make sure you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each key file in your admin dashboard:

import Keycloak from 'keycloak-js';

const keycloak = new Keycloak({
  url: 'http://<KEYCLOAK_EC2_IP>:8180',
  realm: 'gateguard',
  clientId: 'gateguard-admin',
});

export default keycloak;
import React from 'react';
import { ReactKeycloakProvider } from '@react-keycloak/web';
import keycloak from './keycloak';
import AdminRouter from './AdminRouter';

export default function App() {
  return (
    <ReactKeycloakProvider authClient={keycloak}>
      <AdminRouter />
    </ReactKeycloakProvider>
  );
}
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { useKeycloak } from '@react-keycloak/web';
import ManageSubdivisionsPage from './pages/ManageSubdivisionsPage';
import ManageUsersPage from './pages/ManageUsersPage';
import VisitLogsPage from './pages/VisitLogsPage';
import SystemConfigPage from './pages/SystemConfigPage';

export default function AdminRouter() {
  const { keycloak } = useKeycloak();
  if (!keycloak.authenticated) return <button onClick={() => keycloak.login()}>Login</button>;
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/subdivisions" element={<ManageSubdivisionsPage />} />
        <Route path="/users" element={<ManageUsersPage />} />
        <Route path="/visit-logs" element={<VisitLogsPage />} />
        <Route path="/config" element={<SystemConfigPage />} />
      </Routes>
    </BrowserRouter>
  );
}

Your admin dashboard is now running locally with full Keycloak authentication. Administrators can manage subdivisions, users, visit logs, and system settings from the browser. In the Secret Mission, you will take this further with analytics and data visualizations.

Secret mission

Add a Visitor Analytics Dashboard

Your GateGuard system logs every visitor that enters the subdivision. What if admins could see visit trends over time, identify frequent visitors, and pinpoint peak hours at a glance? In this secret mission, you will build an analytics dashboard with interactive charts and CSV export functionality.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. All AWS resources in this project consume from your $200 in credits.

Cost warning

Running both EC2 instances, the RDS database, and associated storage costs approximately $3-5/day from your AWS credits.

If you are not actively developing, stop or terminate your resources to preserve credits. Your AWS Free Plan credits expire 12 months from account creation or when fully consumed.

Resources you used:

  • EC2 instance: gateguard-api (t3.micro, running Spring Boot).
  • EC2 instance: gateguard-keycloak (t3.small, running Keycloak in Docker).
  • RDS instance: gateguard-db (db.t3.micro, PostgreSQL).
  • S3 bucket: gateguard-images (visitor ID image storage).
  • SES verified sender email (email notifications to homeowners).
  • VPC: gateguard-vpc (includes subnets, internet gateway, and security groups).
  • IAM role: gateguard-ec2-role (S3 and SES access).
  • CloudWatch billing alarms and AWS Budget (GateGuard-Budget).
  • Local project folders: gateguard-api and gateguard-admin on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want the system available for demos.

  • Monitor your credit balance daily in the AWS Billing console.
  • Your billing alarms will notify you at $5, $10, $50, and $100 thresholds.
  • At current rates, your $200 in credits provide approximately 5-6 months of continuous uptime.

Pause - I'll come back to this later

Stop running instances to preserve credits while keeping your data intact. You can restart everything later without rebuilding.

  • In the EC2 console, select the gateguard-api instance.
  • Click Instance state, then Stop instance.
  • Repeat for the gateguard-keycloak instance.
  • In the RDS console, select gateguard-db, click Actions, then Stop temporarily.

What still costs credits while paused?

EBS storage attached to your EC2 instances still incurs charges at approximately $0.10/GB/month. Your RDS instance will automatically restart after 7 days if you do not stop it again.

Set a calendar reminder to re-stop RDS every 7 days, or consider deleting if you will be away for more than a week.

Delete - I don't want to use this again

Remove all project resources completely. This stops all credit consumption immediately.

Terminate EC2 instances
  • In the EC2 console, select Instances in the left navigation.
  • Select both gateguard-api and gateguard-keycloak.
  • Click Instance state, then Terminate (delete) instance.
  • Confirm by clicking Terminate (delete).
Delete the RDS database
  • In the RDS console, select Databases in the left navigation.
  • Select gateguard-db, click Actions, then Delete.
  • Check Create final snapshot if you want a backup, or uncheck it to delete everything.
  • Type delete me in the confirmation field and click Delete.
Empty and delete the S3 bucket
  • In the S3 console, select the gateguard-images bucket.
  • Click Empty, type permanently delete to confirm, and click Empty.
  • Return to the bucket list, select gateguard-images again, and click Delete.
  • Enter the bucket name to confirm and click Delete bucket.
Remove the SES verified identity
  • In the SES console, select Identities in the left navigation.
  • Select your verified email identity and click Delete.
Delete the IAM role
  • In the IAM console, select Roles in the left navigation.
  • Select the checkbox next to gateguard-ec2-role and click Delete.
  • Type the role name to confirm and click Delete.
Delete the VPC
  • In the VPC console, select Your VPCs in the left navigation.
  • Select gateguard-vpc, click Actions, then Delete VPC.
  • The console will list associated resources (subnets, security groups, internet gateway) that will be deleted alongside the VPC.
  • Type delete to confirm and click Delete.
Remove billing alarms and budget
  • In the CloudWatch console, select Alarms in the left navigation.
  • Select your billing alarms (BillingAlarm-5 and BillingAlarm-10), click Actions, then Delete.
  • In the AWS Budgets console, select GateGuard-Budget, click Actions, then Delete.
Delete local project files
  • Delete your local project folders by running these commands:
cd ~/Desktop
rm -rf gateguard-api
rm -rf gateguard-admin
  • On Windows, use these commands instead:
cd ~\Desktop
rmdir /s /q gateguard-api
rmdir /s /q gateguard-admin
  • Also delete your Expo mobile app project folder from the Desktop.

That's a Wrap!

That's a Wrap!

You just built and deployed a production-grade, full-stack application from mobile to cloud. This is the kind of system that makes interviewers sit up and take notice.

You've learned how to:

  • Build a React Native mobile app with TypeScript and on-device OCR that scans visitor IDs and sends push notifications to homeowners in real time.
  • Implement enterprise-grade authentication with Keycloak, including OAuth2 social login with Google and Apple, and role-based access control across Guard, Homeowner, and Admin roles.
  • Deploy a complete system to AWS with EC2, RDS, S3, and SES, secured with a VPC and billing alarms to protect your credit budget.
  • Secret Mission: Added a visitor analytics dashboard with Recharts visualizations, peak hours heatmap, frequent visitors tracking, and CSV export to the admin panel.

Ready to quiz yourself?