├── README.md ├── backend ├── .env ├── client-credentials-token-generator.py ├── requirements.txt ├── server.py └── validator.py ├── frontend ├── package.json └── src │ ├── App.js │ ├── Login.js │ ├── authConfig.js │ ├── images │ └── randy-tarampi-U2eUlPEKIgU-unsplash.jpg │ └── style.css └── screenshots ├── 1.png ├── 2.png ├── 3.png ├── 4.png ├── 5.png ├── 6.png ├── 7.png └── 8.png /README.md: -------------------------------------------------------------------------------- 1 | # example-quote-generator-app 2 | A simple web application using a React front-end and a Python back-end API, both secured using ZITADEL. 3 | 4 | Landing Page 9 | 10 | Login Page 15 | 16 | Login Page 21 | 22 | Post-Login Landing Page 27 | 28 | Post API Call 33 | 34 | ## Prerequisites to run the app: 35 | 36 | - Have python3 and pip3 installed in your machine (for the backend) 37 | - Have Node.js installed in your machine (for the frontend) 38 | - Create a free ZITADEL account here - https://zitadel.cloud/ 39 | - Create an instance as explained [here](https://zitadel.com/docs/guides/start/quickstart#2-create-your-first-instance). 40 | - Create a new project in your instance by following the steps [here](https://zitadel.com/docs/guides/start/quickstart#2-create-your-first-instance). 41 | 42 | ## Run the backend 43 | 44 | ### Register the API in ZITADEL 45 | 46 | Follow these [instructions](https://github.com/zitadel/examples-api-access-and-token-introspection/tree/main/api-basic-authentication#1) to register an API application with Basic Authentication in ZITADEL. 47 | 48 | ### Run the API 49 | 50 | The API has a single route: 51 | 52 | - "/api/custom_quote" - A valid access token is required. 53 | 54 | 1. Create a folder named backend and add all the files in [/backend](https://github.com/zitadel/example-quote-generator-app/tree/main/backend) into it. 55 | 2. cd to the backend directory: `cd backend` 56 | 3. Create a new virtual environment for this project by running `python3 -m venv env`. 57 | 4. Install required dependencies by running `pip3 install -r requirements.txt` on your terminal. 58 | 5. Replace the values of `ZITADEL_DOMAIN`, `ZITADEL_INTROSPECTION_URL`, `API_CLIENT_ID` and `API_CLIENT_SECRET` in the .env file with your values you obtained earlier. 59 | 6. Run the API by running `python3 server.py` in the terminal. 60 | 61 | 62 | ### Obtain an access token via service user 63 | 64 | 1. Create a service user as instructed [here]([https://github.com/zitadel/examples-api-access-and-token-introspection/tree/main/service-user-client-credentials](https://github.com/zitadel/examples-api-access-and-token-introspection/tree/main/service-user-client-credentials#2-create-a-service-user-with-client-credentials-in-zitadel-)https://github.com/zitadel/examples-api-access-and-token-introspection/tree/main/service-user-client-credentials#2-create-a-service-user-with-client-credentials-in-zitadel-). You can skip creating the role and authorization. 65 | 2. Obtain a token by running the client-credentials-token-generator.py as instructed [here](https://github.com/zitadel/examples-api-access-and-token-introspection/tree/main/service-user-client-credentials#3-generate-a-token-). You can perform the instructions in this directory in a different terminal. 66 | 67 | ### Test the API with the access token 68 | 69 | 1. Invoke the API using the following cURL command: 70 | `curl -X GET -H "Authorization: Bearer $TOKEN" http://localhost:5000/api/custom_quote` 71 | 72 | 2. You should get a response with Status Code 200 in the following format: 73 | `{"quote":"If you're going through hell, keep going. - Winston Churchill"}` 74 | 75 | Now the API is ready to be consumed by our front-end application. 76 | 77 | ## Run the frontend application 78 | 79 | 1. Follow the ZITADEL [Quickstart Guide](https://zitadel.com/docs/guides/start/quickstart) up to [Create your React application with ZITADEL OIDC PKCE authentication](https://zitadel.com/docs/guides/start/quickstart#create-your-react-application-with-zitadel-oidc-pkce-authentication). We will go through the steps to create the React app for this tutorial below. But before that, here are some changes to note: 80 | - Since you already created an instance and project for the backend, you can use the same project to create the Single Page Application in ZITADEL (or you can follow the guide and create a new project altogether as well). The front-end application and API application were both created in the same ZITADEL project for this app as shown below. 81 | 82 | Project 87 | 88 | - Also, you do not need to create roles and authorizations as stated in the Quickstart. 89 | - Make sure to modify the Zitadel redirect_uri configuration to be just `http://localhost:3000/` (the Quickstart guide uses `http://localhost:3000/callback`). 90 | 91 | Redirect Settings 96 | 97 | - You must also go to Token Settings in the front-end app and select User Info inside ID Token as shown below: 98 | 99 | Token Settings 104 | 105 | 3. Navigate to the folder where you want to create the React app. 106 | 4. Run the following command to create a new React app named "zitadel-app": `npx create-react-app zitadel-app` 107 | 5. Navigate to the "zitadel-app" folder: `cd zitadel-app` 108 | 6. Install the dependencies by running the following: `npm install --save jwt-decode oidc-client-ts react react-dom react-router-dom`\ 109 | 7. Replace the content in your `App.js` file with [src/App.js](https://github.com/zitadel/example-quote-generator-app/blob/main/frontend/src/App.js). 110 | 8. Create a file named `Login.js` and paste the code in [src/Login/js](https://github.com/zitadel/example-quote-generator-app/blob/main/frontend/src/Login.js). 111 | 9. Create a file named `authConfig.js` and add to it the content from [src/authConfig.js](https://github.com/zitadel/example-quote-generator-app/blob/main/frontend/src/authConfig.js). Edit the file by adding your values your obtained from ZITADEL. Make sure the PROJECT_ID in the scope is replaced with the project ID of the project where your API resides. 112 | 10. Add a new file called `style.css` to the src folder to apply CSS styling to the pages. Copy paste the code from [src/style.css](https://github.com/zitadel/example-quote-generator-app/blob/main/frontend/src/style.css). 113 | 11. Create a folder called `images` and add the image in the [/images](https://github.com/zitadel/example-quote-generator-app/tree/main/frontend/src/images) folder. 114 | 12. Add the line `"proxy": "http://localhost:5000"` (the URL of the back-end API) to your `package.json` file so that it looks something like this: 115 | ``` 116 | { 117 | "name": "my-app", 118 | "version": "0.1.0", 119 | "private": true, 120 | "proxy": "http://localhost:5000", 121 | "dependencies": { 122 | "react": "^17.0.1", 123 | "react-dom": "^17.0.1", 124 | "react-scripts": "4.0.1", 125 | //... 126 | }, 127 | //... 128 | } 129 | ``` 130 | 131 | 132 | The "proxy" field in package.json tells the development server to proxy any unknown requests to the specified address. This helps us bypass CORS issues because the requests will be served from the same domain as far as the client (browser) is concerned. 133 | So, in the React code, the fetch request looks like: 134 | 135 | ``` 136 | fetch('/api/custom_quote') 137 | .then(response => response.json()) 138 | .then(data => this.setState({ quote: data.quote })); 139 | ``` 140 | 141 | Please note that this only works when you're running your React app using the development server with `npm start` or `yarn start`. 142 | 143 | 13. Run `npm start` inside the zitadel-app folder. If everything is set up properly, you will have your application running at `http://localhost:3000/`. 144 | 145 | ## Test the web application end-to-end 146 | 147 | 1. Test the application by clicking on the log in button. The front-end app uses Authorization Code with PKCE flow for user authentication using ZITADEL. 148 | 2. The user will be redirected to ZITADEL where he has to log in as a ZITADEL user. 149 | 3. If the login was successful, ZITADEL will send an access token along with an id token to the front-end application. 150 | 4. The front-end application will greet the user and show the option to generate a quote via a button. The user's name was extracted from the id_token returned by ZITADEL. 151 | 5. When the user presses the 'Generate quote" button, the back-end API will be called with the user's access token. 152 | 6. The back-end API is protected and will introspect the access token by calling ZITADEL's introspection endpoint. If the access token is active/valid, the API will send the response to the front-end application. 153 | 7. The user will be abe to view the quote on the browser. 154 | 155 | -------------------------------------------------------------------------------- /backend/.env: -------------------------------------------------------------------------------- 1 | ZITADEL_DOMAIN="https://abc-123-fed-123.zitadel.cloud" # Replace this with your issuer URL 2 | ZITADEL_INTROSPECTION_URL="https://abc-123-fed-123.zitadel.cloud/oauth/v2/introspect" # Replace this with your introspection URL 3 | API_BASE_URL="http://localhost:5000" 4 | API_CLIENT_ID="12345@test_project" # Replace this with the cliend ID of the API APPLICATION (Basic Auth option) 5 | API_CLIENT_SECRET="SECRET23456" # Replace this with the cliend secret of the API APPLICATION (Basic Auth option) 6 | ZITADEL_TOKEN_URL="https://abc-123-fed-123.zitadel.cloud/oauth/v2/token" # Replace this with your token URL 7 | CLIENT_ID="tester" # Replace this with your service user's id (client credentials option) 8 | CLIENT_SECRET="SECRET" # Replace this with your service user's secret (client credentials option) 9 | PROJECT_ID="12453453232323" # Add you Project ID/Resource ID. 10 | -------------------------------------------------------------------------------- /backend/client-credentials-token-generator.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import base64 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv() 7 | 8 | ZITADEL_DOMAIN = os.getenv("ZITADEL_DOMAIN") 9 | CLIENT_ID = os.getenv("CLIENT_ID") 10 | CLIENT_SECRET = os.getenv("CLIENT_SECRET") 11 | ZITADEL_TOKEN_URL = os.getenv("ZITADEL_TOKEN_URL") 12 | PROJECT_ID = os.getenv("PROJECT_ID") 13 | 14 | # Encode the client ID and client secret in Base64 15 | client_credentials = f"{CLIENT_ID}:{CLIENT_SECRET}".encode("utf-8") 16 | base64_client_credentials = base64.b64encode(client_credentials).decode("utf-8") 17 | 18 | # Request an OAuth token from ZITADEL 19 | headers = { 20 | "Content-Type": "application/x-www-form-urlencoded", 21 | "Authorization": f"Basic {base64_client_credentials}" 22 | } 23 | 24 | data = { 25 | "grant_type": "client_credentials", 26 | "scope": f"openid profile email urn:zitadel:iam:org:project:id:{PROJECT_ID}:aud urn:zitadel:iam:org:projects:roles urn:zitadel:iam:org:project:roles" 27 | 28 | 29 | } 30 | 31 | response = requests.post(ZITADEL_TOKEN_URL, headers=headers, data=data) 32 | 33 | if response.status_code == 200: 34 | access_token = response.json()["access_token"] 35 | print(f"Response: {response.json()}") 36 | print(f"Access token: {access_token}") 37 | else: 38 | print(f"Error: {response.status_code} - {response.text}") -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | Authlib==1.2.0 2 | Flask==2.2.2 3 | PyJWT==2.7.0 4 | requests==2.28.2 5 | python-dotenv==0.21.0 6 | Flask-Cors==3.0.10 7 | -------------------------------------------------------------------------------- /backend/server.py: -------------------------------------------------------------------------------- 1 | # server.py 2 | from flask import Flask, jsonify, Response, request 3 | from authlib.integrations.flask_oauth2 import ResourceProtector 4 | from validator import ZitadelIntrospectTokenValidator, ValidatorError 5 | from flask.json import JSONEncoder 6 | import requests 7 | import random 8 | import os 9 | 10 | 11 | class CustomJSONEncoder(JSONEncoder): 12 | def __init__(self, *args, **kwargs): 13 | kwargs['ensure_ascii'] = False # Prevents unicode escapes 14 | super(CustomJSONEncoder, self).__init__(*args, **kwargs) 15 | 16 | 17 | app = Flask(__name__) 18 | app.json_encoder = CustomJSONEncoder 19 | 20 | 21 | require_auth = ResourceProtector() 22 | require_auth.register_token_validator(ZitadelIntrospectTokenValidator()) 23 | 24 | 25 | @app.errorhandler(ValidatorError) 26 | def handle_auth_error(ex: ValidatorError) -> Response: 27 | response = jsonify(ex.error) 28 | response.status_code = ex.status_code 29 | return response 30 | 31 | 32 | @app.route("/api/custom_quote") 33 | @require_auth(None) 34 | def custom_quote(): 35 | quotes = [ 36 | "'Believe you can and you're halfway there'. - Theodore Roosevelt", 37 | "'Don't watch the clock; do what it does. Keep going'. - Sam Levenson", 38 | "'Whether you think you can or you think you can’t, you’re right'. - Henry Ford", 39 | "'The only way to do great work is to love what you do'. - Steve Jobs", 40 | "'You are never too old to set another goal or to dream a new dream'. - C.S. Lewis", 41 | "'The only difference between a rut and a grave is their dimensions'. - Ellen Glasgow", 42 | "'If you're going through hell, keep going'. - Winston Churchill", 43 | "'People often say that motivation doesn't last. Well, neither does bathing – that’s why we recommend it daily'. - Zig Ziglar", 44 | "'If you think you are too small to make a difference, try sleeping with a mosquito'. - Dalai Lama", 45 | "'I find that the harder I work, the more luck I seem to have'. - Thomas Jefferson", 46 | "'I have not failed. I’ve just found 10,000 ways that won’t work'. - Thomas A. Edison" 47 | ] # List of inspirational but funny quotes 48 | 49 | 50 | random_quote = random.choice(quotes) 51 | 52 | return jsonify(quote=random_quote) 53 | 54 | 55 | 56 | 57 | if __name__ == "__main__": 58 | app.run() 59 | -------------------------------------------------------------------------------- /backend/validator.py: -------------------------------------------------------------------------------- 1 | #validator.py 2 | from os import environ as env 3 | import os 4 | import time 5 | from typing import Dict 6 | 7 | from authlib.oauth2.rfc7662 import IntrospectTokenValidator 8 | import requests 9 | from dotenv import load_dotenv, find_dotenv 10 | from requests.auth import HTTPBasicAuth 11 | 12 | load_dotenv() 13 | 14 | ZITADEL_DOMAIN = os.getenv("ZITADEL_DOMAIN") 15 | ZITADEL_INTROSPECTION_URL = os.getenv("ZITADEL_INTROSPECTION_URL") 16 | API_CLIENT_ID = os.getenv("API_CLIENT_ID") 17 | API_CLIENT_SECRET = os.getenv("API_CLIENT_SECRET") 18 | 19 | 20 | class ValidatorError(Exception): 21 | 22 | def __init__(self, error: Dict[str, str], status_code: int): 23 | super().__init__() 24 | self.error = error 25 | self.status_code = status_code 26 | 27 | class ZitadelIntrospectTokenValidator(IntrospectTokenValidator): 28 | def introspect_token(self, token_string): 29 | url = ZITADEL_INTROSPECTION_URL 30 | data = {'token': token_string, 'token_type_hint': 'access_token', 'scope': 'openid'} 31 | auth = HTTPBasicAuth(API_CLIENT_ID, API_CLIENT_SECRET) 32 | resp = requests.post(url, data=data, auth=auth) 33 | resp.raise_for_status() 34 | return resp.json() 35 | 36 | def match_token_scopes(self, token, or_scopes): 37 | if or_scopes is None: 38 | return True 39 | scopes = token.get("scope", "").split() 40 | for and_scopes in or_scopes: 41 | if all(key in scopes for key in and_scopes.split()): 42 | return True 43 | return False 44 | 45 | def validate_token(self, token, scopes, request): 46 | print(f"Token: {token}\n") 47 | now = int(time.time()) 48 | if not token: 49 | raise ValidatorError({ 50 | "code": "invalid_token_revoked", 51 | "description": "Token was revoked." 52 | }, 401) 53 | if token["exp"] < now: 54 | raise ValidatorError({ 55 | "code": "invalid_token_expired", 56 | "description": "Token has expired." 57 | }, 401) 58 | if not token.get("active"): 59 | raise ValidatorError({ 60 | "code": "invalid_token_inactive", 61 | "description": "Token is inactive." 62 | }, 401) 63 | if not self.match_token_scopes(token, scopes): 64 | raise ValidatorError({ 65 | "code": "insufficient_scope", 66 | "description": f"Token has insufficient scope. Route requires: {scopes}" 67 | }, 401) 68 | 69 | 70 | def __call__(self, *args, **kwargs): 71 | res = self.introspect_token(*args, **kwargs) 72 | return res -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zitadel-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "proxy": "http://localhost:5000", 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^5.16.5", 8 | "@testing-library/react": "^13.4.0", 9 | "@testing-library/user-event": "^13.5.0", 10 | "jwt-decode": "^3.1.2", 11 | "oidc-client-ts": "^2.2.4", 12 | "react": "^18.2.0", 13 | "react-dom": "^18.2.0", 14 | "react-router-dom": "^6.13.0", 15 | "react-scripts": "5.0.1", 16 | "web-vitals": "^2.1.4" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test", 22 | "eject": "react-scripts eject" 23 | }, 24 | "eslintConfig": { 25 | "extends": [ 26 | "react-app", 27 | "react-app/jest" 28 | ] 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | //App.js 2 | import React, { useState, useEffect } from "react"; 3 | import { BrowserRouter, Routes, Route } from "react-router-dom"; 4 | import Login from "./Login"; 5 | import authConfig from "./authConfig"; 6 | import { UserManager, WebStorageStateStore } from "oidc-client-ts"; 7 | 8 | function App() { 9 | const [userManager, setUserManager] = useState(null); 10 | const [authenticated, setAuthenticated] = useState(null); 11 | const [userInfo, setUserInfo] = useState(null); 12 | 13 | useEffect(() => { 14 | const manager = new UserManager({ 15 | userStore: new WebStorageStateStore({ store: window.localStorage }), 16 | ...authConfig, 17 | }); 18 | 19 | setUserManager(manager); 20 | }, []); 21 | 22 | useEffect(() => { 23 | if (userManager) { 24 | userManager.getUser().then((user) => { 25 | if (user) { 26 | setAuthenticated(true); 27 | setUserInfo(user); // Store the entire user object 28 | } else { 29 | setAuthenticated(false); 30 | } 31 | }); 32 | 33 | if (window.location.href.includes('id_token') || window.location.href.includes('code')) { 34 | userManager.signinRedirectCallback() 35 | .then((user) => { 36 | if (user) { 37 | console.log('Redirect callback user', user); 38 | setAuthenticated(true); 39 | setUserInfo(user); // Store the entire user object 40 | } 41 | }) 42 | .catch((error) => { 43 | console.error('Sign-in error', error); 44 | }); 45 | } 46 | } 47 | }, [userManager]); 48 | 49 | 50 | 51 | function authorize() { 52 | userManager && userManager.signinRedirect({ state: "a2123a67ff11413fa19217a9ea0fbad5" }); 53 | } 54 | 55 | function clearAuth() { 56 | userManager && userManager.signoutRedirect(); 57 | } 58 | 59 | if (!userManager) { 60 | return
Loading...
; 61 | } 62 | 63 | return ( 64 | 65 | 66 | 76 | } 77 | /> 78 | 79 | 80 | ); 81 | } 82 | 83 | export default App; 84 | 85 | 86 | -------------------------------------------------------------------------------- /frontend/src/Login.js: -------------------------------------------------------------------------------- 1 | //Login.js 2 | import React, { useState, useEffect } from 'react'; 3 | import myImage from './images/randy-tarampi-U2eUlPEKIgU-unsplash.jpg'; 4 | 5 | const Login = ({ auth, handleLogin, handleLogout, userInfo }) => { 6 | const [quote, setQuote] = useState(''); 7 | const [error, setError] = useState(''); 8 | const [loading, setLoading] = useState(false); 9 | 10 | const generateQuote = () => { 11 | setLoading(true); 12 | fetch('/api/custom_quote', { // or use 'http://localhost:5000/api/custom_quote' 13 | headers: { 14 | 'Authorization': `Bearer ${userInfo.access_token}` 15 | } 16 | }) 17 | .then(response => { 18 | if (response.ok) { 19 | return response.json(); 20 | } else { 21 | throw new Error('Error while fetching the quote'); 22 | } 23 | }) 24 | .then(data => { 25 | setQuote(data.quote); 26 | setError(''); 27 | setLoading(false); 28 | }) 29 | .catch((error) => { 30 | setError('Error: Unable to fetch the quote'); 31 | setLoading(false); 32 | }); 33 | }; 34 | 35 | if (auth === null) { 36 | return
Loading...
; 37 | } 38 | 39 | if (auth === false) { 40 | return ( 41 |
42 |

Welcome!

43 | 44 |
45 | ); 46 | } 47 | 48 | if (auth === true && userInfo) { 49 | return ( 50 |
51 |

Welcome, {userInfo.profile.name}!

52 | quote pic {/* Image displayed here */} 53 | {/*

Your access token: {userInfo.access_token}

*/} 54 | 55 | 56 | {loading &&

Loading quote...

} 57 | {error &&

{error}

} 58 | {quote &&

Your personalized quote:

} 59 | {quote &&

Hey there, {userInfo.profile.name}! Here's a fun little quote just for you: {quote}

} 60 | {quote &&

This quote was also emailed to you at {userInfo.profile.email}.

} 61 |
62 | ); 63 | } 64 | 65 | return
Loading...
; 66 | }; 67 | 68 | export default Login; 69 | -------------------------------------------------------------------------------- /frontend/src/authConfig.js: -------------------------------------------------------------------------------- 1 | const authConfig = { 2 | authority: 'https://abc-123-def.zitadel.cloud/', //Replace this with your issuer URL 3 | client_id: 'xyz123456@abcd', //Replace this with your client id 4 | redirect_uri: 'http://localhost:3000/', 5 | response_type: 'code', 6 | scope: 'openid profile email urn:zitadel:iam:org:project:id::aud', //Replace PROJECT_ID with the id of the project where the API resides. 7 | post_logout_redirect_uri: 'http://localhost:3000/', 8 | response_mode: 'query', 9 | code_challenge_method: 'S256', 10 | }; 11 | 12 | export default authConfig; 13 | -------------------------------------------------------------------------------- /frontend/src/images/randy-tarampi-U2eUlPEKIgU-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zitadel/example-quote-generator-app/48c548f61c2ab8d8f390f898506e8e038539fb80/frontend/src/images/randy-tarampi-U2eUlPEKIgU-unsplash.jpg -------------------------------------------------------------------------------- /frontend/src/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #f1f1f1; 3 | align-self: center; 4 | } 5 | 6 | img { 7 | max-width: 100%; 8 | height: auto; 9 | display: block; 10 | margin: 0 auto; 11 | margin-bottom: 20px; 12 | padding: 20px; 13 | align-self: center; 14 | } 15 | 16 | button { 17 | display: block; 18 | margin: auto; 19 | background: rgb(203, 43, 57); 20 | color: white; 21 | border-radius: 6px; 22 | height: 36px; 23 | padding: 0.5rem 1rem; 24 | border: none; 25 | font-weight: 500; 26 | font-size: 14px; 27 | margin-bottom: 20px; /* Adds space below buttons */ 28 | 29 | &:not([disabled]) { 30 | cursor: pointer; 31 | } 32 | 33 | &:hover { 34 | border: none; 35 | outline: none; 36 | box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.1); 37 | } 38 | 39 | &:active { 40 | background: rgb(206, 56, 68); 41 | border: none; 42 | outline: none; 43 | } 44 | 45 | &:focus { 46 | border: none; 47 | outline: none; 48 | } 49 | } 50 | 51 | h1, 52 | p { 53 | text-align: center; 54 | } 55 | 56 | h2 { 57 | text-align: center; 58 | color: grey; 59 | margin-top: 60px; 60 | } 61 | -------------------------------------------------------------------------------- /screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zitadel/example-quote-generator-app/48c548f61c2ab8d8f390f898506e8e038539fb80/screenshots/1.png -------------------------------------------------------------------------------- /screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zitadel/example-quote-generator-app/48c548f61c2ab8d8f390f898506e8e038539fb80/screenshots/2.png -------------------------------------------------------------------------------- /screenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zitadel/example-quote-generator-app/48c548f61c2ab8d8f390f898506e8e038539fb80/screenshots/3.png -------------------------------------------------------------------------------- /screenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zitadel/example-quote-generator-app/48c548f61c2ab8d8f390f898506e8e038539fb80/screenshots/4.png -------------------------------------------------------------------------------- /screenshots/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zitadel/example-quote-generator-app/48c548f61c2ab8d8f390f898506e8e038539fb80/screenshots/5.png -------------------------------------------------------------------------------- /screenshots/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zitadel/example-quote-generator-app/48c548f61c2ab8d8f390f898506e8e038539fb80/screenshots/6.png -------------------------------------------------------------------------------- /screenshots/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zitadel/example-quote-generator-app/48c548f61c2ab8d8f390f898506e8e038539fb80/screenshots/7.png -------------------------------------------------------------------------------- /screenshots/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zitadel/example-quote-generator-app/48c548f61c2ab8d8f390f898506e8e038539fb80/screenshots/8.png --------------------------------------------------------------------------------