47
.env.example
Normal file
|
@ -0,0 +1,47 @@
|
|||
NEXT_PUBLIC_FIREBASE_PUBLIC_API_KEY=
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
|
||||
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
|
||||
|
||||
NEXT_PUBLIC_ROOT_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_SHA=development
|
||||
NEXT_PUBLIC_BRANCH=local
|
||||
|
||||
# Stripe Payments
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||
NEXT_PUBLIC_STRIPE_PRICE_ID=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
MONGODB_URI="mongodb://localhost:27017"
|
||||
MONGODB_DB=""
|
||||
|
||||
# Discord Interactions API
|
||||
DISCORD_CLIENT_ID=""
|
||||
DISCORD_CLIENT_SECRET=""
|
||||
DISCORD_APP_ID=""
|
||||
DISCORD_TOKEN=""
|
||||
DISCORD_PUBLIC_KEY=""
|
||||
DISCORD_GUILD_ID=""
|
||||
|
||||
# Roblox OAuth2 API
|
||||
NEXT_PUBLIC_ROBLOX_CLIENT_ID=""
|
||||
ROBLOX_CLIENT_SECRET=""
|
||||
|
||||
# Mail
|
||||
SENDGRID_API_KEY=
|
||||
|
||||
# Firebase Authentication and Image Storage
|
||||
FIREBASE_ADMIN_auth_provider_x509_cert_url=
|
||||
FIREBASE_ADMIN_auth_uri=
|
||||
FIREBASE_ADMIN_client_email=
|
||||
FIREBASE_ADMIN_client_id=
|
||||
FIREBASE_ADMIN_client_x509_cert_url=
|
||||
|
||||
# (Encoded in base64)
|
||||
FIREBASE_ADMIN_private_key=
|
||||
|
||||
FIREBASE_ADMIN_private_key_id=
|
||||
FIREBASE_ADMIN_project_id=
|
||||
FIREBASE_ADMIN_token_uri=
|
||||
FIREBASE_ADMIN_type=
|
||||
|
6
.eslintrc.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"react/prop-types": 0
|
||||
}
|
||||
}
|
38
.gitignore
vendored
Normal file
|
@ -0,0 +1,38 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.development
|
||||
.env.production
|
||||
.env.test
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
31
.prettierrc
Normal file
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"editor.formatOnSave": true,
|
||||
"proseWrap": "always",
|
||||
"tabWidth": 2,
|
||||
"requireConfig": false,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"semi": true,
|
||||
"singleAttributePerLine": true,
|
||||
"importOrder": [
|
||||
"^react$",
|
||||
"^@chakra-ui/react",
|
||||
"^@chakra-ui/icons",
|
||||
"^react-icons/(.*)$",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@/lib/(.*)$",
|
||||
"^@/contexts/(.*)$",
|
||||
"^@/hooks/(.*)$",
|
||||
"^@/util/(.*)$",
|
||||
"^@/assets/(.*)$",
|
||||
"^@/layouts/(.*)$",
|
||||
"^@/components/(.*)$",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true
|
||||
}
|
21
LICENSE.md
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2023 Pete Pongpeauk
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
43
README.md
Normal file
|
@ -0,0 +1,43 @@
|
|||
# R&C XCS - Card Access Management Platform
|
||||
|
||||
R&C XCS is a card access management platform that allows users to manage their card access to access points within experiences on the Roblox platform.
|
||||
|
||||
### Features:
|
||||
- Create and manage access points
|
||||
- Organization and location-based access control
|
||||
- Different allow-list parameters (e.g. card numbers, user ID, group ID assocation, etc.)
|
||||
- Access groups
|
||||
- Scan logs
|
||||
- Member management
|
||||
- Invitation links for organizations and registration
|
||||
- ...and more!
|
||||
|
||||
This project uses the following technologies:
|
||||
- [React](https://reactjs.org/)
|
||||
- [Next.js](https://nextjs.org/)
|
||||
- [MongoDB](https://www.mongodb.com/)
|
||||
|
||||
The backend is *serverless* and is bundled with Next.js in the `src/pages/api` directory.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en/) (v20.8.0 or higher)
|
||||
- [Bun](https://bun.sh/) (1.0.7 or higher, recommended) or [npm](https://www.npmjs.com/) (9.8.0 or higher)
|
||||
- [MongoDB Community Server](https://www.mongodb.com/)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Clone the repository
|
||||
2. Run `bun install` or `npm install` to install dependencies
|
||||
3. Clone `.env.example` to `.env` and fill in the values
|
||||
3. Run `bun dev` or `npm run dev` to start the development server
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is open to contributions. I don't have a contributing guide yet, but feel free to open a pull request and I'll review it.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT license. See [LICENSE.md](LICENSE.md) for more information.
|
15
components.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "styles/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": false
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
103
next.config.js
Normal file
|
@ -0,0 +1,103 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
enabled: process.env.ANALYZE === 'true'
|
||||
})
|
||||
|
||||
// const withPWA = require('next-pwa')({
|
||||
// dest: 'public',
|
||||
// register: true,
|
||||
// skipWaiting: true,
|
||||
// disable: true,
|
||||
// });
|
||||
|
||||
module.exports = withBundleAnalyzer({
|
||||
reactStrictMode: true,
|
||||
async rewrites() {
|
||||
return [
|
||||
// legacy oauth endpoints
|
||||
{
|
||||
source: '/platform/verify/:slug*',
|
||||
destination: '/verify/:slug*'
|
||||
},
|
||||
{
|
||||
source: '/@:username',
|
||||
destination: '/users/:username'
|
||||
},
|
||||
{
|
||||
source: '/api/v1/roblox/users/:slug*',
|
||||
destination: 'https://users.roblox.com/:slug*'
|
||||
},
|
||||
{
|
||||
source: '/api/v1/roblox/groups/:slug*',
|
||||
destination: 'https://groups.roblox.com/:slug*'
|
||||
},
|
||||
{
|
||||
source: '/api/v1/roblox/games/:slug*',
|
||||
destination: 'https://games.roblox.com/:slug*'
|
||||
},
|
||||
{
|
||||
source: '/api/v1/roblox/thumbnails/:slug*',
|
||||
destination: 'https://thumbnails.roblox.com/:slug*'
|
||||
},
|
||||
{
|
||||
source: '/api/v1/ip-api/:slug*',
|
||||
destination: 'http://ip-api.com/:slug*'
|
||||
}
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/login',
|
||||
destination: '/auth/login',
|
||||
permanent: true
|
||||
},
|
||||
{
|
||||
source: '/signup',
|
||||
destination: '/auth/signup',
|
||||
permanent: true
|
||||
},
|
||||
{
|
||||
source: '/logout',
|
||||
destination: '/auth/logout',
|
||||
permanent: true
|
||||
},
|
||||
{
|
||||
source: '/platform',
|
||||
destination: '/platform/home',
|
||||
permanent: true
|
||||
},
|
||||
{
|
||||
source: '/platform/:slug*',
|
||||
destination: '/:slug*',
|
||||
permanent: false
|
||||
},
|
||||
{
|
||||
source: '/users/:slug',
|
||||
destination: '/@:slug',
|
||||
permanent: false
|
||||
},
|
||||
{
|
||||
source: '/user/:slug',
|
||||
destination: '/@:slug',
|
||||
permanent: false
|
||||
},
|
||||
{
|
||||
source: '/invite/:slug*',
|
||||
destination: '/invitation/:slug*',
|
||||
permanent: false
|
||||
},
|
||||
{
|
||||
source: '/AxesysAPI/:slug*',
|
||||
destination: '/api/v1/axesys/:slug*',
|
||||
permanent: false
|
||||
},
|
||||
{
|
||||
source: '/api/v1/AxesysAPI/:slug*',
|
||||
destination: '/api/v1/axesys/:slug*',
|
||||
permanent: false
|
||||
}
|
||||
];
|
||||
}
|
||||
});
|
79
package.json
Normal file
|
@ -0,0 +1,79 @@
|
|||
{
|
||||
"name": "xcs4",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"analyze": "cross-env ANALYZE=true next build",
|
||||
"analyze:server": "cross-env BUNDLE_ANALYZE=server next build",
|
||||
"analyze:browser": "cross-env BUNDLE_ANALYZE=browser next build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/icons": "^2.0.19",
|
||||
"@chakra-ui/next-js": "^2.1.5",
|
||||
"@chakra-ui/react": "^2.8.0",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@monaco-editor/react": "^4.5.1",
|
||||
"@mui/material": "^5.13.7",
|
||||
"@mui/x-data-grid": "^6.9.2",
|
||||
"@sendgrid/mail": "^7.7.0",
|
||||
"@tanstack/react-query": "^4.32.6",
|
||||
"@tanstack/react-table": "^8.9.3",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
||||
"@types/gm": "^1.25.1",
|
||||
"@types/node": "^20.5.0",
|
||||
"@types/randomstring": "^1.1.8",
|
||||
"@types/react": "18.2.14",
|
||||
"@types/react-dom": "18.2.6",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"autoprefixer": "10.4.14",
|
||||
"chakra-react-select": "^4.7.0",
|
||||
"class-variance-authority": "^0.6.1",
|
||||
"clsx": "^1.2.1",
|
||||
"discord-api-types": "^0.37.50",
|
||||
"eslint": "8.44.0",
|
||||
"eslint-config-next": "13.4.7",
|
||||
"firebase": "^9.23.0",
|
||||
"firebase-admin": "^11.9.0",
|
||||
"formik": "^2.4.2",
|
||||
"framer-motion": "^10.13.0",
|
||||
"generate-api-key": "^1.0.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lru-cache": "7.12.0",
|
||||
"mergician": "^1.1.0",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.43",
|
||||
"mongodb": "^5.6.0",
|
||||
"next": "13.4.7",
|
||||
"next-pwa": "^5.6.0",
|
||||
"nextjs-progressbar": "^0.0.16",
|
||||
"postcss": "8.4.24",
|
||||
"prettier": "^3.0.0",
|
||||
"randomstring": "^1.3.0",
|
||||
"react": "18.2.0",
|
||||
"react-data-grid": "^7.0.0-beta.34",
|
||||
"react-dom": "18.2.0",
|
||||
"react-fast-marquee": "^1.6.0",
|
||||
"react-firebase": "^2.2.8",
|
||||
"react-firebase-hooks": "^5.1.1",
|
||||
"react-icons": "^4.10.1",
|
||||
"react-markdown": "^9.0.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-html": "^16.0.1",
|
||||
"request-ip": "^3.3.0",
|
||||
"sharp": "^0.32.6",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"tailwindcss": "3.3.2",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"typescript": "5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^13.4.13",
|
||||
"cross-env": "^7.0.3"
|
||||
}
|
||||
}
|
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
81
posts/platform-updates-2023-08-13.md
Normal file
|
@ -0,0 +1,81 @@
|
|||
---
|
||||
title: 'Platform Updates - 2023-08-13'
|
||||
date: '2023-08-13'
|
||||
|
||||
thumbnail: '/images/blog/launch/1.png'
|
||||
thumbnailAlt: 'R&C XCS'
|
||||
|
||||
author: 'restrafes'
|
||||
authorImage: '/images/authors/restrafes.png'
|
||||
|
||||
category: 'Updates'
|
||||
---
|
||||
|
||||
Hello, and welcome to the first ever blog post on the platform! For our first post, we'll be going over some of the
|
||||
updates that have been made to the platform since our beta launch.
|
||||
|
||||
Let's get started!
|
||||
|
||||
## Adding members just got an upgrade!
|
||||
|
||||
We've updated the member add flow to be more intuitive and easier to use. The new flow is shown below:
|
||||
|
||||

|
||||
|
||||
## 🔥🔥 Card member types 🔥🔥
|
||||
|
||||
In an ongoing effort to make XCS a full replacement to traditional access point readers, we've added a new member type:
|
||||
card numbers! You can now specify what card numbers are accepted for each access point. Ranges are supported, so you
|
||||
don't need to add each card number individually (e.g. 1-24.)
|
||||
|
||||
## In-app invitations have arrived!
|
||||
|
||||
We've added an in-app invitation system for organizations. This allows you to invite members to your organization
|
||||
without having to share a link. Of course, you can still share a link if you want to.
|
||||
|
||||

|
||||
|
||||
## Access group priority levels
|
||||
|
||||
You can now specify a priority level for each access group. This allows you to specify which access groups' scan data
|
||||
take precedence over others.
|
||||
|
||||
For example, if you have an access group with a priority level of 1 with the following scan data:
|
||||
|
||||
{
|
||||
"isRestricted": true,
|
||||
"allowedFloors": [1, 3]
|
||||
}
|
||||
|
||||
-and another access group with a priority level of 2 with the following scan data:
|
||||
|
||||
{
|
||||
"isRestricted": false,
|
||||
"allowedFloors": [1, 4, 5]
|
||||
}
|
||||
|
||||
-the following scan data will be returned for a member with both access groups:
|
||||
|
||||
{
|
||||
"isRestricted": false,
|
||||
"allowedFloors": [1, 3, 4, 5]
|
||||
}
|
||||
|
||||
## Beta testers now get a sweet new badge! 🎉
|
||||
|
||||
We've added a new badge for beta testers! If you registered your XCS account before the platform was launched to the
|
||||
public, you should have the badge already.
|
||||
|
||||

|
||||
|
||||
## Other features & changes
|
||||
|
||||
- You are now able to upload custom icons to your organization.
|
||||
- Organizations now get a public page.
|
||||
|
||||
That wraps up this blog post! We hope you enjoy the features as much as we enjoyed making them. If you have any feedback
|
||||
or suggestions, please let us know in our [official Discord server!](https://discord.gg/BWVa3yE9M3)
|
||||
|
||||
Until next time,
|
||||
|
||||
restrafes
|
BIN
public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 7 KiB |
BIN
public/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 569 B |
BIN
public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
public/favicon-alt.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
public/favicon-alt2.ico
Normal file
After Width: | Height: | Size: 7.2 KiB |
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
public/images/7534-dababy.png
Normal file
After Width: | Height: | Size: 142 KiB |
BIN
public/images/achievements/early-access.png
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
public/images/authors/restrafes.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
BIN
public/images/blog/launch/1.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
public/images/blog/updates-2023-08-13/1.jpeg
Normal file
After Width: | Height: | Size: 36 KiB |
BIN
public/images/blog/updates-2023-08-13/2.jpeg
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
public/images/blog/updates-2023-08-13/3.jpeg
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
public/images/default-avatar-card.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
public/images/default-avatar-location.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
public/images/default-avatar-organization.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
public/images/default-avatar.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
BIN
public/images/hero1.jpg
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
public/images/hero2.jpeg
Normal file
After Width: | Height: | Size: 627 KiB |
BIN
public/images/hero3.jpg
Normal file
After Width: | Height: | Size: 264 KiB |
BIN
public/images/hero4.jpeg
Normal file
After Width: | Height: | Size: 261 KiB |
BIN
public/images/hero5.jpeg
Normal file
After Width: | Height: | Size: 465 KiB |
BIN
public/images/hero6.png
Normal file
After Width: | Height: | Size: 761 KiB |
BIN
public/images/home-hero.jpg
Normal file
After Width: | Height: | Size: 429 KiB |
BIN
public/images/icons/icon-128x128.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
public/images/icons/icon-144x144.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
BIN
public/images/icons/icon-152x152.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
BIN
public/images/icons/icon-192x192.png
Normal file
After Width: | Height: | Size: 4.1 KiB |
BIN
public/images/icons/icon-384x384.png
Normal file
After Width: | Height: | Size: 8.3 KiB |
BIN
public/images/icons/icon-512x512.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
public/images/icons/icon-72x72.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
public/images/icons/icon-96x96.png
Normal file
After Width: | Height: | Size: 2 KiB |
BIN
public/images/icons/icon-apple.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
public/images/login1.jpeg
Normal file
After Width: | Height: | Size: 233 KiB |
BIN
public/images/login2.jpeg
Normal file
After Width: | Height: | Size: 379 KiB |
BIN
public/images/login4.jpeg
Normal file
After Width: | Height: | Size: 302 KiB |
BIN
public/images/logo-black.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
public/images/logo-black.png.old.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
public/images/logo-black2.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
public/images/logo-square.jpeg
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
public/images/logo-square.jpg
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
public/images/logo-square.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
public/images/logo-white.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
public/images/logo-white.png.old.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
public/images/logo-white2.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
public/images/logo.jpg
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
public/images/splashscreens/ipad_splash.png
Normal file
After Width: | Height: | Size: 68 KiB |
BIN
public/images/splashscreens/ipadpro1_splash.png
Normal file
After Width: | Height: | Size: 79 KiB |
BIN
public/images/splashscreens/ipadpro2_splash.png
Normal file
After Width: | Height: | Size: 113 KiB |
BIN
public/images/splashscreens/ipadpro3_splash.png
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
public/images/splashscreens/iphone5_splash.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
public/images/splashscreens/iphone6_splash.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
public/images/splashscreens/iphoneplus_splash.png
Normal file
After Width: | Height: | Size: 62 KiB |
BIN
public/images/splashscreens/iphonex_splash.png
Normal file
After Width: | Height: | Size: 64 KiB |
BIN
public/images/splashscreens/iphonexr_splash.png
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
public/images/splashscreens/iphonexsmax_splash.png
Normal file
After Width: | Height: | Size: 74 KiB |
52
public/manifest.json
Normal file
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "R&C XCS",
|
||||
"short_name": "XCS",
|
||||
"theme_color": "#fff",
|
||||
"background_color": "#000",
|
||||
"display": "standalone",
|
||||
"orientation": "",
|
||||
"scope": "/",
|
||||
"start_url": "/auth/login",
|
||||
"icons": [
|
||||
{
|
||||
"src": "images/icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "images/icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "images/icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "images/icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "images/icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "images/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "images/icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "images/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
1
public/next.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
public/site.webmanifest
Normal file
|
@ -0,0 +1 @@
|
|||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
1
public/vercel.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
After Width: | Height: | Size: 629 B |
1
src/blacklist.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const blacklist = {};
|
652
src/components/AccessGroupEditModal.tsx
Normal file
|
@ -0,0 +1,652 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
NumberDecrementStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
Portal,
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
Spacer,
|
||||
Stack,
|
||||
Switch,
|
||||
Table, TableCaption, TableContainer, Tbody, Td, Text,
|
||||
Th, Thead,
|
||||
Tr,
|
||||
VStack,
|
||||
chakra,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
|
||||
import { IoIosCreate, IoIosRemoveCircle } from 'react-icons/io';
|
||||
import { IoSave } from 'react-icons/io5';
|
||||
import { MdEditSquare } from 'react-icons/md';
|
||||
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
|
||||
import DeleteDialog from '@/components/DeleteDialog';
|
||||
|
||||
import CreateAccessGroupDialog from './CreateAccessGroupDialog';
|
||||
|
||||
const ChakraEditor = chakra(Editor);
|
||||
|
||||
export default function AccessGroupEditModal({
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
onRefresh,
|
||||
clientMember,
|
||||
groups,
|
||||
organization,
|
||||
location,
|
||||
onGroupRemove
|
||||
}: any) {
|
||||
const { user } = useAuthContext();
|
||||
const toast = useToast();
|
||||
const [focusedGroup, setFocusedGroup] = useState<any>(null);
|
||||
const themeBorderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
const groupSearchRef = useRef<any>(null);
|
||||
const [filteredGroups, setFilteredGroups] = useState<any>([]);
|
||||
|
||||
const editButtonsRef = useRef<any>(null);
|
||||
|
||||
const {
|
||||
isOpen: deleteGroupDialogOpen,
|
||||
onOpen: deleteGroupDialogOnOpen,
|
||||
onClose: deleteGroupDialogOnClose
|
||||
} = useDisclosure();
|
||||
|
||||
const { isOpen: createModalOpen, onOpen: createModalOnOpen, onClose: createModalOnClose } = useDisclosure();
|
||||
|
||||
const filterGroups = useCallback((query: string) => {
|
||||
if (!query) return groups;
|
||||
return Object.keys(groups)
|
||||
.filter((group: any) => groups[group].name.toLowerCase().includes(query.toLowerCase()))
|
||||
.map((group: any) => groups[group]);
|
||||
}, [groups]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredGroups(groups || {});
|
||||
}, [groups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!organization) return;
|
||||
setFilteredGroups(filterGroups(groupSearchRef?.current?.value));
|
||||
if (focusedGroup) {
|
||||
setFocusedGroup(Object.values(groups as any).find((group: any) => group.id === focusedGroup.id));
|
||||
}
|
||||
}, [organization]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteDialog
|
||||
isOpen={deleteGroupDialogOpen}
|
||||
onClose={deleteGroupDialogOnClose}
|
||||
title="Delete Access Group"
|
||||
body={`Are you sure you want to delete the ${focusedGroup?.name} access group from this organization? All members and access points that have this access group will be updated to reflect this change.`}
|
||||
buttonText="Delete"
|
||||
onDelete={() => {
|
||||
deleteGroupDialogOnClose();
|
||||
onGroupRemove(focusedGroup);
|
||||
setFocusedGroup(null);
|
||||
}}
|
||||
/>
|
||||
<CreateAccessGroupDialog
|
||||
isOpen={createModalOpen}
|
||||
onClose={createModalOnClose}
|
||||
organization={organization}
|
||||
location={location}
|
||||
onCreate={(group: any) => {
|
||||
onRefresh();
|
||||
createModalOnClose();
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
closeOnOverlayClick={false}
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent
|
||||
maxW={{ base: 'full', lg: 'container.xl' }}
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
h="100%"
|
||||
>
|
||||
<ModalHeader>Manage Access Groups</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody
|
||||
w={'full'}
|
||||
pb={0}
|
||||
h="100%"
|
||||
>
|
||||
<VStack
|
||||
w="100%"
|
||||
h="100%"
|
||||
overflow={{
|
||||
base: 'auto',
|
||||
xl: 'hidden'
|
||||
}}
|
||||
overscrollBehavior={'contain'}
|
||||
>
|
||||
<Stack
|
||||
w="100%"
|
||||
mb={4}
|
||||
direction={{ base: 'column', md: 'row' }}
|
||||
>
|
||||
<FormControl w={{ base: 'full', md: '300px' }}>
|
||||
<FormLabel>Search Access Group</FormLabel>
|
||||
<Input
|
||||
placeholder={'Search for an access group...'}
|
||||
ref={groupSearchRef}
|
||||
onChange={(e) => {
|
||||
if (e.target?.value) {
|
||||
setFilteredGroups(filterGroups(e.target?.value));
|
||||
} else {
|
||||
setFilteredGroups(groups);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Spacer />
|
||||
<Button
|
||||
alignSelf={{
|
||||
base: 'normal',
|
||||
md: 'flex-end'
|
||||
}}
|
||||
onClick={createModalOnOpen}
|
||||
leftIcon={<IoIosCreate />}
|
||||
>
|
||||
New Access Group
|
||||
</Button>
|
||||
</Stack>
|
||||
<Flex
|
||||
w={'full'}
|
||||
justify={'space-between'}
|
||||
flexDir={{ base: 'column', xl: 'row' }}
|
||||
h="full"
|
||||
overflow="auto"
|
||||
overscrollBehavior={'contain'}
|
||||
>
|
||||
<TableContainer
|
||||
py={2}
|
||||
minH={{ base: '320px', xl: '100%' }}
|
||||
overflowY={'auto'}
|
||||
overscrollBehavior={'contain'}
|
||||
flexGrow={1}
|
||||
px={4}
|
||||
>
|
||||
<Table size={{ base: 'sm', md: 'sm' }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th isNumeric>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{organization ? (
|
||||
Object.keys(filteredGroups).map((group: any) => (
|
||||
<Tr key={group}>
|
||||
<Td>
|
||||
<Box my={2}>
|
||||
<Text
|
||||
fontWeight="bold"
|
||||
alignItems={'center'}
|
||||
display={'flex'}
|
||||
>
|
||||
{filteredGroups[group].name}
|
||||
{filteredGroups[group].config?.openToEveryone && (
|
||||
<Badge
|
||||
as={'span'}
|
||||
ml={2}
|
||||
>
|
||||
Everyone
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
as={'span'}
|
||||
ml={2}
|
||||
colorScheme={filteredGroups[group].config?.active ? 'green' : 'red'}
|
||||
>
|
||||
{filteredGroups[group].config?.active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</Text>
|
||||
{filteredGroups[group].description && (
|
||||
<Text
|
||||
color={'gray.500'}
|
||||
maxW={'384px'}
|
||||
isTruncated
|
||||
>
|
||||
{filteredGroups[group].description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<MdEditSquare />}
|
||||
onClick={() => {
|
||||
setFocusedGroup(filteredGroups[group]);
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label="Delete Access Group"
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
ml={2}
|
||||
icon={<IoIosRemoveCircle />}
|
||||
onClick={() => {
|
||||
setFocusedGroup(filteredGroups[group]);
|
||||
deleteGroupDialogOnOpen();
|
||||
}}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{Array.from(Array(8).keys()).map((i) => (
|
||||
<Tr key={i}>
|
||||
<Td>
|
||||
<SkeletonText
|
||||
noOfLines={1}
|
||||
spacing="4"
|
||||
skeletonHeight={4}
|
||||
/>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<SkeletonText
|
||||
noOfLines={1}
|
||||
spacing="4"
|
||||
skeletonHeight={4}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Tbody>
|
||||
{(!Object.keys(groups || {})?.length || !Object.keys(filteredGroups || {})?.length) && (
|
||||
<TableCaption>No access groups found.</TableCaption>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{/* Edit Group */}
|
||||
<Skeleton
|
||||
isLoaded={organization}
|
||||
rounded={'lg'}
|
||||
minW={{
|
||||
base: 'unset',
|
||||
sm: 'unset',
|
||||
lg: '512px'
|
||||
}}
|
||||
flexBasis={1}
|
||||
>
|
||||
<Flex
|
||||
p={6}
|
||||
rounded={'lg'}
|
||||
border={'1px solid'}
|
||||
borderColor={themeBorderColor}
|
||||
h={'full'}
|
||||
overflowY={'auto'}
|
||||
overscrollBehavior={'contain'}
|
||||
>
|
||||
{!focusedGroup || !organization ? (
|
||||
<Text
|
||||
m={'auto'}
|
||||
variant={'subtext'}
|
||||
fontWeight={'bold'}
|
||||
>
|
||||
Select an access group to manage.
|
||||
</Text>
|
||||
) : (
|
||||
<Flex
|
||||
flexDir={'column'}
|
||||
w={'full'}
|
||||
>
|
||||
{/* Header */}
|
||||
<Flex
|
||||
align={'center'}
|
||||
h={'fit-content'}
|
||||
>
|
||||
<Flex flexDir={'column'}>
|
||||
<Flex align={'center'}>
|
||||
<Text
|
||||
as={'h2'}
|
||||
fontSize={'2xl'}
|
||||
fontWeight={'bold'}
|
||||
>
|
||||
{focusedGroup?.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{/* Body */}
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={{
|
||||
name: focusedGroup?.name,
|
||||
description: focusedGroup?.description || '',
|
||||
priority: focusedGroup?.priority || 1,
|
||||
scanData: JSON.stringify(focusedGroup?.scanData, null, 3),
|
||||
// config
|
||||
configActive: focusedGroup?.config?.active,
|
||||
configOpenToEveryone: focusedGroup?.config?.openToEveryone
|
||||
}}
|
||||
onSubmit={(values, actions) => {
|
||||
user.getIdToken().then((token: string) => {
|
||||
fetch(`/api/v1/organizations/${organization?.id}/access-groups/${focusedGroup?.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: values?.name,
|
||||
locationId: location?.id,
|
||||
description: values?.description,
|
||||
scanData: values?.scanData,
|
||||
priority: values?.priority,
|
||||
config: {
|
||||
active: values?.configActive,
|
||||
openToEveryone: values?.configOpenToEveryone
|
||||
}
|
||||
})
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
toast({
|
||||
title: data.message,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
onRefresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error updating the access group.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Form
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
flexDir={'column'}
|
||||
mt={4}
|
||||
w={'full'}
|
||||
pb={8}
|
||||
>
|
||||
<Stack>
|
||||
<Stack
|
||||
direction={{
|
||||
base: 'column',
|
||||
md: 'column'
|
||||
}}
|
||||
>
|
||||
<Field name="name">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl w={'fit-content'}>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
type={'text'}
|
||||
variant={'outline'}
|
||||
placeholder={'Access Group Name'}
|
||||
autoComplete={'off'}
|
||||
autoCorrect={'off'}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="description">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Short Description</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
type={'text'}
|
||||
variant={'outline'}
|
||||
placeholder={'Short Description'}
|
||||
autoComplete={'off'}
|
||||
autoCorrect={'off'}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="priority">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl w={'fit-content'}>
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<InputGroup>
|
||||
<NumberInput
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Priority"
|
||||
variant={'outline'}
|
||||
min={1}
|
||||
defaultValue={1}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('priority', value);
|
||||
}}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction={{
|
||||
base: 'column',
|
||||
md: 'row'
|
||||
}}
|
||||
py={2}
|
||||
w={'fit-content'}
|
||||
>
|
||||
<Field name="configActive">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl w={'fit-content'}>
|
||||
<FormLabel>Active</FormLabel>
|
||||
<InputGroup>
|
||||
<Switch
|
||||
{...field}
|
||||
placeholder="Active"
|
||||
variant={'outline'}
|
||||
width={'fit-content'}
|
||||
isChecked={form.values?.configActive}
|
||||
onChange={(e) => {
|
||||
form.setFieldValue('configActive', e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
<FormHelperText>Whether or not this access group is active.</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="configOpenToEveryone">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl w={'fit-content'}>
|
||||
<FormLabel>Everyone</FormLabel>
|
||||
<InputGroup>
|
||||
<Switch
|
||||
{...field}
|
||||
colorScheme={'red'}
|
||||
placeholder="Everyone"
|
||||
variant={'outline'}
|
||||
width={'fit-content'}
|
||||
isChecked={form.values?.configOpenToEveryone}
|
||||
onChange={(e) => {
|
||||
form.setFieldValue('configOpenToEveryone', e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
<FormHelperText>
|
||||
Whether or not this access group is open to everyone whose access is granted.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</Stack>
|
||||
<Field name="scanData">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl w={'fit-content'}>
|
||||
<FormLabel>Scan Data</FormLabel>
|
||||
<InputGroup>
|
||||
<Box
|
||||
border={'1px solid'}
|
||||
borderColor={themeBorderColor}
|
||||
borderRadius={'lg'}
|
||||
w={'full'}
|
||||
overflow={'hidden'}
|
||||
>
|
||||
<ChakraEditor
|
||||
{...field}
|
||||
height="240px"
|
||||
width="100%"
|
||||
p={4}
|
||||
language="json"
|
||||
theme={useColorModeValue('vs-light', 'vs-dark')}
|
||||
options={{
|
||||
minimap: {
|
||||
enabled: false
|
||||
}
|
||||
}}
|
||||
value={form.values?.scanData}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('scanData', value);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</InputGroup>
|
||||
<FormHelperText>
|
||||
This is the data that will be returned when a user under this access group
|
||||
scans their card. (User scan data takes priority over access group scan data
|
||||
when it is merged).
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</Stack>
|
||||
</Flex>
|
||||
<Portal containerRef={editButtonsRef}>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
spacing={4}
|
||||
>
|
||||
<Button
|
||||
isLoading={props.isSubmitting}
|
||||
leftIcon={<IoSave />}
|
||||
type={'submit'}
|
||||
onClick={() => {
|
||||
props.handleSubmit();
|
||||
}}
|
||||
onSubmit={() => {
|
||||
props.handleSubmit();
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="red"
|
||||
leftIcon={<IoIosRemoveCircle />}
|
||||
onClick={() => {
|
||||
setFocusedGroup(focusedGroup);
|
||||
deleteGroupDialogOnOpen();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Stack>
|
||||
</Portal>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Skeleton>
|
||||
</Flex>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Stack
|
||||
direction={{ base: 'column', md: 'row' }}
|
||||
spacing={4}
|
||||
>
|
||||
<Box ref={editButtonsRef} />
|
||||
<Button
|
||||
colorScheme="black"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Stack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
136
src/components/CheckActivationCodeModal.tsx
Normal file
|
@ -0,0 +1,136 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useRef } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Text,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export default function CheckActivationCodeModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const initialRef = useRef(null);
|
||||
const { user } = useAuthContext();
|
||||
const { push } = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
initialValues={{ code: '' }}
|
||||
onSubmit={async (values, actions) => {
|
||||
const reformatCode = values.code.replace(`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/`, '');
|
||||
await actions.setValues({ 'code': reformatCode });
|
||||
await fetch(`/api/v1/activation/${encodeURIComponent(reformatCode || 0)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
onClose();
|
||||
push(`/auth/activate/${reformatCode}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
initialFocusRef={initialRef}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<Form>
|
||||
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<ModalHeader pb={2}>Activation Code</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={4}>
|
||||
<VStack spacing={2}>
|
||||
<Field name="code">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Activation Code</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
ref={initialRef}
|
||||
value={field.value}
|
||||
variant={'outline'}
|
||||
placeholder={'Code'}
|
||||
autoComplete={'off'}
|
||||
autoCorrect={'off'}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</VStack>
|
||||
<Text
|
||||
fontSize={'sm'}
|
||||
color={"gray.500"}
|
||||
pt={2}
|
||||
>
|
||||
Have an activation code? Enter it here to create an account.
|
||||
</Text>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
mr={3}
|
||||
isLoading={props.isSubmitting}
|
||||
type={'submit'}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
164
src/components/CreateAccessGroupDialog.tsx
Normal file
|
@ -0,0 +1,164 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useRef } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Text,
|
||||
Textarea,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
|
||||
export default function CreateAccessGroupDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
organization,
|
||||
location,
|
||||
onCreate
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
organization: any;
|
||||
location?: any;
|
||||
onCreate: (location: any) => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const initialRef = useRef(null);
|
||||
const finalRef = useRef(null);
|
||||
const { user } = useAuthContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
initialValues={{ name: '', description: '', scanData: {} }}
|
||||
onSubmit={(values, actions) => {
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/organizations/${organization.id}/access-groups`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
locationId: location?.id,
|
||||
description: values.description,
|
||||
scanData: {}
|
||||
})
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
toast({
|
||||
title: data.message,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
actions.resetForm();
|
||||
onClose();
|
||||
onCreate(data.id);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error creating the access group.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
initialFocusRef={initialRef}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<Form>
|
||||
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<ModalHeader pb={2}>New Access Group</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={4}>
|
||||
<VStack spacing={2}>
|
||||
<Field name="name">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
type={'text'}
|
||||
variant={'outline'}
|
||||
placeholder={'Access Group Name'}
|
||||
ref={initialRef}
|
||||
autoCorrect={'off'}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="description">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Short Description</FormLabel>
|
||||
<Textarea
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
placeholder={'Access Group Short Description'}
|
||||
maxH={'1rem'}
|
||||
autoComplete={'off'}
|
||||
autoCorrect={'off'}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
mr={3}
|
||||
isLoading={props.isSubmitting}
|
||||
type={'submit'}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
204
src/components/CreateAccessPointDialog.tsx
Normal file
|
@ -0,0 +1,204 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useRef } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Text,
|
||||
Textarea,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
|
||||
import { getRandomAccessPointName } from '@/lib/utils';
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
import { AccessPoint, Location } from '@/types';
|
||||
import { Select } from 'chakra-react-select';
|
||||
|
||||
export default function CreateAccessPointDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
location,
|
||||
onCreate,
|
||||
accessPoints
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
location: Location;
|
||||
onCreate: (location: any) => void;
|
||||
accessPoints: AccessPoint[];
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const initialRef = useRef(null);
|
||||
const finalRef = useRef(null);
|
||||
const { user } = useAuthContext();
|
||||
|
||||
const namePlaceholder = getRandomAccessPointName();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
initialValues={{ name: '', description: '', template: null as null | { label: string, value: any } }}
|
||||
onSubmit={(values, actions) => {
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/locations/${location.id}/access-points`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
locationId: location.id,
|
||||
templateId: values.template?.value || null
|
||||
})
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
toast({
|
||||
title: data.message,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
actions.resetForm();
|
||||
onClose();
|
||||
onCreate(data.accessPointId);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error creating the access point.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
initialFocusRef={initialRef}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<Form>
|
||||
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<ModalHeader pb={2}>New Access Point</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={4}>
|
||||
<VStack spacing={2}>
|
||||
<Field name="name">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
ref={initialRef}
|
||||
variant={'outline'}
|
||||
placeholder={namePlaceholder || 'Access Point Name'}
|
||||
autoComplete='off'
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="template">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Copy Configuration from Access Point</FormLabel>
|
||||
<Select
|
||||
{...field}
|
||||
options={(accessPoints || []).map((ap: AccessPoint) => ({
|
||||
value: ap.id,
|
||||
label: ap.name
|
||||
})) || []}
|
||||
placeholder={'Access Point (optional)'}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('template', value);
|
||||
}}
|
||||
value={field.value}
|
||||
single={true}
|
||||
hideSelectedOptions={false}
|
||||
selectedOptionStyle={'check'}
|
||||
isClearable={true}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Use an existing access point's configuration.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="description">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
placeholder={'Access Point Description'}
|
||||
maxH={'200px'}
|
||||
/>
|
||||
<FormHelperText>
|
||||
This access point will be created under the{' '}
|
||||
<Text
|
||||
as={'span'}
|
||||
fontWeight={'bold'}
|
||||
>
|
||||
{location?.name}
|
||||
</Text>{' '}
|
||||
location.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
mr={3}
|
||||
isLoading={props.isSubmitting}
|
||||
type={'submit'}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
185
src/components/CreateLocationDialog.tsx
Normal file
|
@ -0,0 +1,185 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Text,
|
||||
Textarea,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
import { getRandomLocationName } from '@/lib/utils';
|
||||
|
||||
export default function CreateLocationDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedOrganization,
|
||||
onCreate
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedOrganization: any;
|
||||
onCreate: (location: any) => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const initialRef = useRef(null);
|
||||
const finalRef = useRef(null);
|
||||
const { user } = useAuthContext();
|
||||
|
||||
const namePlaceholder = getRandomLocationName();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
initialValues={{ name: '', description: '' }}
|
||||
onSubmit={(values, actions) => {
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch('/api/v1/locations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
organizationId: selectedOrganization.id
|
||||
})
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
toast({
|
||||
title: data.message,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
actions.resetForm();
|
||||
onClose();
|
||||
onCreate(data.locationId);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error creating the location.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
initialFocusRef={initialRef}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<Form>
|
||||
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<ModalHeader pb={2}>New Location</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={4}>
|
||||
<VStack spacing={2}>
|
||||
<Field name="name">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
placeholder={namePlaceholder || 'Location Name'}
|
||||
ref={initialRef}
|
||||
autoComplete='off'
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
{/* <Field name="organization">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Organization</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
variant={"outline"}
|
||||
value={selectedOrganization.name}
|
||||
isDisabled={true}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field> */}
|
||||
<Field name="description">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
placeholder={'Location Description'}
|
||||
maxH={'200px'}
|
||||
/>
|
||||
<FormHelperText>
|
||||
This location will be created under the{' '}
|
||||
<Text
|
||||
as={'span'}
|
||||
fontWeight={'bold'}
|
||||
>
|
||||
{selectedOrganization?.name}
|
||||
</Text>{' '}
|
||||
organization.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
mr={3}
|
||||
isLoading={props.isSubmitting}
|
||||
type={'submit'}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
176
src/components/CreateOrganizationDialog.tsx
Normal file
|
@ -0,0 +1,176 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Input,
|
||||
Link,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Text,
|
||||
Textarea,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import NextLink from 'next/link';
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
import { getRandomOrganizationName } from '@/lib/utils';
|
||||
|
||||
export default function CreateOrganizationDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreate
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (location: any) => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const initialRef = useRef(null);
|
||||
const finalRef = useRef(null);
|
||||
const { user } = useAuthContext();
|
||||
|
||||
const namePlaceholder = getRandomOrganizationName();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
initialValues={{ name: '' }}
|
||||
onSubmit={(values, actions) => {
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch('/api/v1/organizations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: values.name
|
||||
})
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
toast({
|
||||
title: data.message,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
actions.resetForm();
|
||||
onClose();
|
||||
onCreate(data.organizationId);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error creating the organization.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
initialFocusRef={initialRef}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<Form>
|
||||
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<ModalHeader pb={2}>New Organization</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={4}>
|
||||
<VStack spacing={2}>
|
||||
<Field name="name">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
placeholder={namePlaceholder || 'Organization Name'}
|
||||
ref={initialRef}
|
||||
autoComplete={'off'}
|
||||
/>
|
||||
<FormHelperText>
|
||||
By creating an organization, you agree to our{' '}
|
||||
<Text as={'span'}>
|
||||
<Link
|
||||
as={NextLink}
|
||||
href={'/legal/terms'}
|
||||
target={'_blank'}
|
||||
textDecor={'underline'}
|
||||
textUnderlineOffset={4}
|
||||
whiteSpace={'nowrap'}
|
||||
>
|
||||
Terms of Use
|
||||
</Link>
|
||||
</Text>{' '}
|
||||
and{' '}
|
||||
<Text as={'span'}>
|
||||
<Link
|
||||
as={NextLink}
|
||||
href={'/legal/privacy'}
|
||||
target={'_blank'}
|
||||
textDecor={'underline'}
|
||||
textUnderlineOffset={4}
|
||||
whiteSpace={'nowrap'}
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</Text>
|
||||
.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
mr={3}
|
||||
isLoading={props.isSubmitting}
|
||||
type={'submit'}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
85
src/components/DataTable.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { Table, Tbody, Td, Th, Thead, Tr, chakra } from '@chakra-ui/react';
|
||||
|
||||
import { TriangleDownIcon, TriangleUpIcon } from '@chakra-ui/icons';
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
export type DataTableProps<Data extends object> = {
|
||||
data: Data[];
|
||||
columns: ColumnDef<Data, any>[];
|
||||
};
|
||||
|
||||
export function DataTable<Data extends object>({ data, columns }: DataTableProps<Data>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
state: {
|
||||
sorting
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
// see https://tanstack.com/table/v8/docs/api/core/column-def#meta to type this correctly
|
||||
const meta: any = header.column.columnDef.meta;
|
||||
return (
|
||||
<Th
|
||||
key={header.id}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
isNumeric={meta?.isNumeric}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
|
||||
<chakra.span pl={'4'}>
|
||||
{header.column.getIsSorted() ? (
|
||||
header.column.getIsSorted() === 'desc' ? (
|
||||
<TriangleDownIcon aria-label="sorted descending" />
|
||||
) : (
|
||||
<TriangleUpIcon aria-label="sorted ascending" />
|
||||
)
|
||||
) : null}
|
||||
</chakra.span>
|
||||
</Th>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Tr key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
// see https://tanstack.com/table/v8/docs/api/core/column-def#meta to type this correctly
|
||||
const meta: any = cell.column.columnDef.meta;
|
||||
return (
|
||||
<Td
|
||||
key={cell.id}
|
||||
isNumeric={meta?.isNumeric}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
62
src/components/DeleteDialog.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogCloseButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
export default function DeleteDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
cancelRef,
|
||||
onDelete,
|
||||
title,
|
||||
body,
|
||||
buttonText = 'Delete'
|
||||
}: any) {
|
||||
return (
|
||||
<>
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
leastDestructiveRef={cancelRef}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<AlertDialogHeader
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
pb={2}
|
||||
>
|
||||
{title ? title : 'Delete item'}
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>{body ? body : "Are you sure? You can't undo this action afterwards."}</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme={'red'}
|
||||
onClick={onDelete}
|
||||
ml={3}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
100
src/components/DeleteDialogOrganization.tsx
Normal file
|
@ -0,0 +1,100 @@
|
|||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Image,
|
||||
Input,
|
||||
Text,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
function DeleteButtonIcon() {
|
||||
return <>
|
||||
<Image
|
||||
src={'/images/7534-dababy.png'}
|
||||
alt={'Delete'}
|
||||
width={6}
|
||||
height={6}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
export default function DeleteDialogOrganization({
|
||||
isOpen,
|
||||
onClose,
|
||||
cancelRef,
|
||||
onDelete,
|
||||
buttonText = 'I Understand, Delete Organization',
|
||||
organization
|
||||
}: any) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
leastDestructiveRef={cancelRef}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
size={'lg'}
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<AlertDialogHeader
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
pb={2}
|
||||
>
|
||||
Delete Organization
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
Are you sure you want to delete this organization? This will remove all associated data, including locations, access points, access groups, and API keys.<br /><Text as={'strong'}>This action cannot be undone.</Text>
|
||||
<FormControl my={4}>
|
||||
<FormLabel>Organization Name</FormLabel>
|
||||
<Input
|
||||
placeholder={`Type "${organization?.name}" to confirm`}
|
||||
ref={inputRef}
|
||||
onChange={(e) => {
|
||||
setDisabled(e.target.value !== organization?.name);
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Type "{organization?.name}" to confirm
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
</AlertDialogBody>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme={'red'}
|
||||
onClick={onDelete}
|
||||
ml={3}
|
||||
isDisabled={disabled}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
64
src/components/Error.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { Link } from '@chakra-ui/next-js';
|
||||
import { Box, Button, Container, Flex, Heading, Text } from '@chakra-ui/react';
|
||||
import Head from 'next/head';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export default function Error({ statusCode }: { statusCode: string }) {
|
||||
const ErrorPageMessages = {
|
||||
"404": {
|
||||
title: 'Page Not Found',
|
||||
message: 'The page you are looking for does not exist.',
|
||||
},
|
||||
"403": {
|
||||
title: 'Unauthorized',
|
||||
message: 'You do not have permission to view this page.',
|
||||
},
|
||||
"401": {
|
||||
title: 'Unauthorized',
|
||||
message: 'You do not have permission to view this page.',
|
||||
},
|
||||
"500": {
|
||||
title: 'Server Error',
|
||||
message: 'An error occurred on the server.',
|
||||
},
|
||||
} as { [key: string]: ErrorPageMessage };
|
||||
|
||||
interface ErrorPageMessage {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const { title, message }: ErrorPageMessage = useMemo(() => {
|
||||
return ErrorPageMessages[statusCode] || ErrorPageMessages['500'];
|
||||
}, [statusCode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title} - Restrafes XCS</title>
|
||||
</Head>
|
||||
<Container
|
||||
maxW={'container.sm'}
|
||||
py={16}
|
||||
>
|
||||
<Text
|
||||
as={'h1'}
|
||||
fontSize={'4xl'}
|
||||
fontWeight={'900'}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize={'lg'}
|
||||
mb={4}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
<Flex flexDir={'row'} gap={2}>
|
||||
<Button as={Link} href={'/'} _hover={{ textDecoration: 'none' }}>Return to Home</Button>
|
||||
<Button as={Link} href={'/home'} _hover={{ textDecoration: 'none' }}>Return to Platform Home</Button>
|
||||
</Flex>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
69
src/components/Footer.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { Divider, Flex, Icon, Link, Text, useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
import NextLink from 'next/link';
|
||||
import { BiGitBranch } from 'react-icons/bi';
|
||||
|
||||
export default function Footer({ type = 'platform' }: { type?: 'public' | 'platform' }) {
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
as="footer"
|
||||
position={'sticky'}
|
||||
top={0}
|
||||
flexDir={'column'}
|
||||
w={'100%'}
|
||||
h={'8rem'}
|
||||
border={'1px solid'}
|
||||
borderLeft={{ base: '1px solid', md: 'unset' }}
|
||||
borderColor={useColorModeValue('gray.300', 'gray.700')}
|
||||
// borderColor={type === 'public' ? useColorModeValue('blackAlpha.900', 'whiteAlpha.900') : useColorModeValue('gray.300', 'gray.700')}
|
||||
p={4}
|
||||
align={'center'}
|
||||
justify={'center'}
|
||||
>
|
||||
<Text>
|
||||
<Text
|
||||
as={'span'}
|
||||
fontWeight={'bold'}
|
||||
letterSpacing={'tight'}
|
||||
>
|
||||
© RESTRAFES & CO LLC.
|
||||
</Text>{' '}
|
||||
All rights reserved.
|
||||
</Text>
|
||||
<Flex align={'center'} justify={'center'} fontSize={'sm'}>
|
||||
<Link
|
||||
as={NextLink}
|
||||
href={'/legal/terms'}
|
||||
>
|
||||
Terms of Use
|
||||
</Link>
|
||||
<Divider
|
||||
orientation={'vertical'}
|
||||
mx={2}
|
||||
h={'1rem'}
|
||||
borderColor={useColorModeValue('gray.300', 'gray.700')}
|
||||
/>
|
||||
<Link
|
||||
as={NextLink}
|
||||
href={'/legal/privacy'}
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</Flex>
|
||||
<Flex
|
||||
color={'gray.500'}
|
||||
fontSize={"sm"}
|
||||
align={'center'}
|
||||
>
|
||||
<Icon as={BiGitBranch} mr={1} />{" "}
|
||||
<Text as={'span'}>
|
||||
{process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA || 'dev-mode'}{" "}
|
||||
({process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF || 'dev'})
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
212
src/components/Home.tsx
Normal file
|
@ -0,0 +1,212 @@
|
|||
import { Box, Button, Container, Flex, Heading, Icon, Image, Spacer, Text, chakra, useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
import moment from 'moment';
|
||||
import NextImage from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import Marquee from 'react-fast-marquee';
|
||||
import { BsArrowRight } from 'react-icons/bs';
|
||||
import Section from './section';
|
||||
|
||||
const ChakraImage = chakra(NextImage, {
|
||||
baseStyle: { maxH: 120, maxW: 120 },
|
||||
shouldForwardProp: (prop) => ['width', 'height', 'src', 'alt'].includes(prop),
|
||||
});
|
||||
|
||||
const ChakraMarquee = chakra(Marquee, {
|
||||
baseStyle: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
export default function Home({ allPostsData: posts }: { allPostsData: any }) {
|
||||
return (
|
||||
// New Bold Typography Design
|
||||
<>
|
||||
<Container
|
||||
as={Flex}
|
||||
flexDir={'column'}
|
||||
maxW={'100%'}
|
||||
position={'relative'}
|
||||
p={0}
|
||||
pt={16}
|
||||
align={'center'}
|
||||
>
|
||||
<Flex
|
||||
flexDir={'column'}
|
||||
position={'relative'}
|
||||
maxW={'container.xl'}
|
||||
h={'calc(100dvh)'}
|
||||
borderBottom={'1px solid'}
|
||||
borderColor={useColorModeValue('blackAlpha.900', 'whiteAlpha.900')}
|
||||
mx={{ base: 4, md: 16 }}
|
||||
pb={24}
|
||||
>
|
||||
<Section>
|
||||
<Heading as={'h1'} size={{ base: 'xl', md: '4xl' }} fontWeight={'normal'} pb={{ base: 8, md: 16 }} w={{ base: 'full', md: '66%' }}>
|
||||
Powering the future of access control.
|
||||
</Heading>
|
||||
</Section>
|
||||
<Flex
|
||||
flexBasis={1}
|
||||
flexGrow={2}
|
||||
overflow={'hidden'}
|
||||
w={'full'}
|
||||
>
|
||||
<Image
|
||||
src={'/images/home-hero.jpg'}
|
||||
alt={'Home Image'}
|
||||
w={"full"}
|
||||
height={"full"}
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{/* <Flex
|
||||
flexDir={'column'}
|
||||
minH={'50vh'}
|
||||
w={'container.xl'}
|
||||
mx={{ base: 4, md: 16 }}
|
||||
py={16}
|
||||
borderBottom={'1px solid'}
|
||||
borderColor={useColorModeValue('blackAlpha.900', 'whiteAlpha.900')}
|
||||
>
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexBasis={1}
|
||||
pb={8}
|
||||
display={{ base: 'none', md: 'block' }}
|
||||
>
|
||||
<Heading
|
||||
size={'xl'}
|
||||
fontWeight={'400'}
|
||||
pb={2}
|
||||
>
|
||||
Blog Posts
|
||||
</Heading>
|
||||
<Spacer />
|
||||
</Box>
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexBasis={1}
|
||||
>
|
||||
<Flex flexDir={'column'} gap={4} w={'540px'}>
|
||||
<Link href={`/blog/${posts[0].id}`}>
|
||||
<Image src={posts[0].thumbnail} alt={posts[0].thumbnailAlt} objectFit={'cover'} aspectRatio={2 / 1} />
|
||||
</Link>
|
||||
<Flex flexDir={'column'}>
|
||||
<Heading size={'md'}>
|
||||
{posts[0].title}
|
||||
</Heading>
|
||||
<Text>
|
||||
{moment(posts[0].date).format('MMMM Do, YYYY')}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex> */}
|
||||
<Flex
|
||||
minH={'50vh'}
|
||||
maxW={'container.xl'}
|
||||
mx={{ base: 4, md: 16 }}
|
||||
py={16}
|
||||
borderBottom={'1px solid'}
|
||||
borderColor={useColorModeValue('blackAlpha.900', 'whiteAlpha.900')}
|
||||
>
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexBasis={1}
|
||||
display={{ base: 'none', md: 'block' }}
|
||||
>
|
||||
<Heading
|
||||
size={'xl'}
|
||||
fontWeight={'400'}
|
||||
pb={2}
|
||||
>
|
||||
Qu'est-ce que Restrafes XCS?
|
||||
</Heading>
|
||||
</Box>
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexBasis={1}
|
||||
>
|
||||
<Heading
|
||||
size={'xl'}
|
||||
fontWeight={'400'}
|
||||
pb={2}
|
||||
>
|
||||
What is Restrafes XCS?
|
||||
</Heading>
|
||||
<Text fontSize={'xl'}>
|
||||
Restrafes XCS is a powerful access control system designed to help you manage access points for your
|
||||
building. With Restrafes XCS, you can easily and securely control who has access to your property, including
|
||||
employees and visitors. The system is highly customizable, allowing you to set access levels and permissions
|
||||
for different users, and offers a range of advanced features such as real-time monitoring, reporting, and
|
||||
reverse-compatibility with other systems. Whether you're looking to enhance the security of your
|
||||
business or residential property, Restrafes XCS provides the flexibility and reliability you need
|
||||
to manage access with confidence.
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex
|
||||
flexDir={{ base: 'column', md: 'row' }}
|
||||
w={'100%'}
|
||||
maxW={'container.xl'}
|
||||
mx={{ base: 4, md: 16 }}
|
||||
justify={{ base: 'center', md: 'space-between' }}
|
||||
gap={4}
|
||||
align={'center'}
|
||||
py={24}
|
||||
>
|
||||
<Box>
|
||||
<Heading
|
||||
size={'2xl'}
|
||||
fontWeight={'normal'}
|
||||
>
|
||||
Get started today.
|
||||
</Heading>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text
|
||||
fontSize={'xl'}
|
||||
maxW={'24ch'}
|
||||
>
|
||||
Open access coming soon. Available now for beta testers.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
my={4}
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
h={'auto'}
|
||||
// variant={pathname === href ? 'solid' : variant}
|
||||
variant={'unstyled'}
|
||||
border={'1px solid'}
|
||||
borderColor={useColorModeValue('blackAlpha.900', 'white')}
|
||||
borderRadius={'none'}
|
||||
py={2}
|
||||
px={4}
|
||||
href={'/auth/login'}
|
||||
transition={'all 0.2s ease'}
|
||||
_hover={{
|
||||
bg: useColorModeValue('blackAlpha.900', 'white'),
|
||||
color: useColorModeValue('white', 'black')
|
||||
}}
|
||||
_active={{
|
||||
bg: useColorModeValue('blackAlpha.700', 'white'),
|
||||
color: useColorModeValue('white', 'black')
|
||||
}}
|
||||
lineHeight={1.25}
|
||||
>
|
||||
Access Platform
|
||||
<Icon as={BsArrowRight} ml={1} h={3} />
|
||||
</Button>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
5
src/components/InspectScanModal.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
export default function InspectScanModal() {
|
||||
return <>
|
||||
|
||||
</>
|
||||
}
|
300
src/components/Invitation.tsx
Normal file
|
@ -0,0 +1,300 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
Icon,
|
||||
Image,
|
||||
Skeleton,
|
||||
Text,
|
||||
chakra,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { Link } from '@chakra-ui/next-js';
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
import { Invitation } from '@/types';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FiArrowRight } from 'react-icons/fi';
|
||||
|
||||
export default function Invitation({ invite, errorMessage }: { invite: Invitation, errorMessage: string | null }) {
|
||||
const { query, push } = useRouter();
|
||||
const toast = useToast();
|
||||
const [isAcceptLoading, setIsAcceptLoading] = useState<boolean>(false);
|
||||
const { user, currentUser } = useAuthContext();
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
let { id: queryId } = query;
|
||||
const id = queryId?.length ? queryId[0] : null;
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [user]);
|
||||
|
||||
const acceptInvite = async () => {
|
||||
setIsAcceptLoading(true);
|
||||
if (invite.type === 'organization') {
|
||||
push(`/organizations/?invitation=${query.id}`);
|
||||
} else if (invite.type === 'xcs') {
|
||||
push(`/auth/activate/${query.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const inviteTypeSwitch = (type: string) => {
|
||||
switch (type) {
|
||||
case 'organization':
|
||||
return 'join their organization';
|
||||
case 'xcs':
|
||||
return 'create an account.';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Invitation - Restrafes XCS</title>
|
||||
</Head>
|
||||
<Container
|
||||
maxW={'container.lg'}
|
||||
h={'100dvh'}
|
||||
>
|
||||
<Flex
|
||||
pos={'relative'}
|
||||
flexDir={'column'}
|
||||
align={'center'}
|
||||
justify={'center'}
|
||||
h={'full'}
|
||||
bottom={[0, 8]}
|
||||
>
|
||||
<Link
|
||||
my={8}
|
||||
href={'/'}
|
||||
>
|
||||
<Image
|
||||
src={useColorModeValue('/images/logo-black.png', '/images/logo-white.png')}
|
||||
alt={'Restrafes XCS Logo'}
|
||||
w={'auto'}
|
||||
h={'24px'}
|
||||
objectFit={'contain'}
|
||||
transition={'filter 0.2s ease'}
|
||||
_hover={{
|
||||
filter: useColorModeValue('opacity(0.75)', 'brightness(0.75)')
|
||||
}}
|
||||
_active={{
|
||||
filter: useColorModeValue('opacity(0.5)', 'brightness(0.5)')
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<Flex
|
||||
maxW={['100%', 'lg']}
|
||||
// aspectRatio={invite ? 1 / 1.25 : 'unset'}
|
||||
minH={invite ? 'xl' : 'unset'}
|
||||
rounded={'lg'}
|
||||
border={['none', '1px solid']}
|
||||
borderColor={['none', useColorModeValue('gray.300', 'gray.600')]}
|
||||
direction={'column'}
|
||||
align={'center'}
|
||||
justify={'space-between'}
|
||||
p={[4, 8]}
|
||||
>
|
||||
<Box w={'full'}>
|
||||
<Skeleton isLoaded={!loading}>
|
||||
<Text
|
||||
as={'h2'}
|
||||
fontSize={{ base: '2xl', sm: '3xl' }}
|
||||
fontWeight={'900'}
|
||||
letterSpacing={'tight'}
|
||||
w={'full'}
|
||||
textAlign={'center'}
|
||||
>
|
||||
{invite
|
||||
? invite.type === 'organization'
|
||||
? "You've recieved an invitation"
|
||||
: "You're invited to register"
|
||||
: 'Invitation not found'}
|
||||
</Text>
|
||||
</Skeleton>
|
||||
<Skeleton isLoaded={!loading}>
|
||||
<Text
|
||||
fontSize={'lg'}
|
||||
mb={2}
|
||||
textAlign={'center'}
|
||||
>
|
||||
{invite ? (
|
||||
<>
|
||||
{invite?.creator?.displayName || invite?.creator?.name?.first} has invited you to{' '}
|
||||
{inviteTypeSwitch(invite?.type)}
|
||||
{invite?.type === 'organization' ? (
|
||||
<Text
|
||||
as={'span'}
|
||||
fontWeight={'bold'}
|
||||
>
|
||||
{', '}
|
||||
{invite.organization?.name}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>{!errorMessage ? "The invitation you are looking for is either invalid or no longer exists." : errorMessage}</>
|
||||
)}
|
||||
</Text>
|
||||
</Skeleton>
|
||||
</Box>
|
||||
{invite ? (
|
||||
<>
|
||||
<Flex
|
||||
flexDir={'row'}
|
||||
align={'center'}
|
||||
justify={'center'}
|
||||
flexGrow={1}
|
||||
w={'full'}
|
||||
p={4}
|
||||
>
|
||||
<Skeleton
|
||||
display={'flex'}
|
||||
isLoaded={!loading}
|
||||
objectFit={'contain'}
|
||||
justifyContent={'center'}
|
||||
rounded={'full'}
|
||||
>
|
||||
<Avatar
|
||||
src={invite?.creator?.avatar || '/images/default-avatar.png'}
|
||||
size={'full'}
|
||||
maxW={'240px'}
|
||||
aspectRatio={1 / 1}
|
||||
outline={'1px solid'}
|
||||
outlineColor={useColorModeValue('gray.300', 'gray.600')}
|
||||
/>
|
||||
</Skeleton>
|
||||
{
|
||||
invite.type === 'organization' && (
|
||||
<>
|
||||
<Icon as={FiArrowRight} fontSize={'3xl'} mx={4} />
|
||||
<Skeleton
|
||||
display={'flex'}
|
||||
isLoaded={!loading}
|
||||
objectFit={'contain'}
|
||||
justifyContent={'center'}
|
||||
rounded={'full'}
|
||||
>
|
||||
<Avatar
|
||||
src={invite?.organization?.avatar || '/images/default-avatar-organization.png'}
|
||||
size={'full'}
|
||||
maxW={'240px'}
|
||||
aspectRatio={1 / 1}
|
||||
outline={'1px solid'}
|
||||
outlineColor={useColorModeValue('gray.300', 'gray.600')}
|
||||
borderRadius={'lg'}
|
||||
/>
|
||||
</Skeleton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Flex>
|
||||
<Box w={'full'}>
|
||||
<Skeleton isLoaded={!loading}>
|
||||
{currentUser || invite.type === 'xcs' ? (
|
||||
<Button
|
||||
w={'full'}
|
||||
my={2}
|
||||
isLoading={isAcceptLoading}
|
||||
onClick={acceptInvite}
|
||||
isDisabled={invite?.type === 'xcs' && currentUser}
|
||||
>
|
||||
{invite?.type === 'xcs'
|
||||
? currentUser
|
||||
? 'You are logged in'
|
||||
: 'Register & accept'
|
||||
: 'Accept invitation'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
w={'full'}
|
||||
my={2}
|
||||
isLoading={isAcceptLoading}
|
||||
onClick={() => {
|
||||
setIsAcceptLoading(true);
|
||||
push('/login?redirect=/invitation/' + query.id);
|
||||
}}
|
||||
>
|
||||
Login to accept
|
||||
</Button>
|
||||
)}
|
||||
</Skeleton>
|
||||
<Skeleton isLoaded={!loading}>
|
||||
<Text
|
||||
fontSize={'sm'}
|
||||
my={2}
|
||||
textAlign={'center'}
|
||||
>
|
||||
By accepting this invitation, you agree to the{' '}
|
||||
<chakra.div as={'span'} whiteSpace={'nowrap'}>
|
||||
<Text
|
||||
as={'span'}
|
||||
fontWeight={'bold'}
|
||||
>
|
||||
Restrafes XCS
|
||||
</Text>{' '}
|
||||
<Text
|
||||
as={'span'}
|
||||
fontWeight={'bold'}
|
||||
whiteSpace={'nowrap'}
|
||||
>
|
||||
<Link
|
||||
href={'/legal/terms'}
|
||||
textDecor={'underline'}
|
||||
textUnderlineOffset={4}
|
||||
>
|
||||
Terms of Use
|
||||
</Link>
|
||||
</Text>{' '}
|
||||
and{' '}
|
||||
<Text
|
||||
as={'span'}
|
||||
fontWeight={'bold'}
|
||||
whiteSpace={'nowrap'}
|
||||
>
|
||||
<Link
|
||||
href={'/legal/privacy'}
|
||||
textDecor={'underline'}
|
||||
textUnderlineOffset={4}
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</chakra.div>
|
||||
</Text>
|
||||
</Skeleton>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box w={'full'}>
|
||||
<Button
|
||||
as={Link}
|
||||
href={'/'}
|
||||
w={'full'}
|
||||
mt={4}
|
||||
>
|
||||
Return to Home
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
887
src/components/InviteOrganizationFlowModal.tsx
Normal file
|
@ -0,0 +1,887 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Icon,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
RadioGroup,
|
||||
Spacer,
|
||||
Stack,
|
||||
Step,
|
||||
StepIcon,
|
||||
StepIndicator,
|
||||
StepSeparator,
|
||||
StepStatus,
|
||||
Stepper,
|
||||
Text,
|
||||
chakra,
|
||||
useColorModeValue,
|
||||
useRadio,
|
||||
useRadioGroup,
|
||||
useSteps,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { AsyncSelect, CreatableSelect, Select } from 'chakra-react-select';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
import { AiFillIdcard } from 'react-icons/ai';
|
||||
import { FaIdBadge } from 'react-icons/fa';
|
||||
import { SiRoblox, SiRobloxstudio } from 'react-icons/si';
|
||||
|
||||
const steps = [
|
||||
{ description: 'Choose a Member Type' },
|
||||
{ description: 'Enter Member Details' },
|
||||
{ description: 'Completed' },
|
||||
]
|
||||
|
||||
interface SelectOption {
|
||||
label?: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
function RadioCard(props: any) {
|
||||
const { getInputProps, getRadioProps } = useRadio(props as any)
|
||||
|
||||
const input = getInputProps()
|
||||
const checkbox = getRadioProps()
|
||||
|
||||
return (
|
||||
<Box as='label'>
|
||||
<input {...input} />
|
||||
<Box
|
||||
{...checkbox}
|
||||
cursor='pointer'
|
||||
borderWidth='1px'
|
||||
borderRadius='lg'
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
transition={'background 0.2s ease-out'}
|
||||
_hover={{
|
||||
bg: useColorModeValue('gray.50', 'gray.700'),
|
||||
}}
|
||||
_checked={{
|
||||
bg: useColorModeValue('gray.100', 'gray.700'),
|
||||
}}
|
||||
_active={{
|
||||
bg: useColorModeValue('gray.200', 'gray.600'),
|
||||
}}
|
||||
// _focus={{
|
||||
// boxShadow: 'outline',
|
||||
// }}
|
||||
px={5}
|
||||
py={3}
|
||||
>
|
||||
{props.children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default function InviteOrganizationFlowModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAdd,
|
||||
organization,
|
||||
accessGroupOptions
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: () => void;
|
||||
organization: any;
|
||||
accessGroupOptions: any;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const initialRef = useRef(null);
|
||||
const finalRef = useRef(null);
|
||||
const formRef = useRef(null) as any;
|
||||
const [groupRoles, setGroupRoles] = useState<any>([]);
|
||||
const [lastGroupId, setLastGroupId] = useState<any>('');
|
||||
const groupIdRef = useRef<any>(null);
|
||||
const { user } = useAuthContext();
|
||||
const { activeStep, setActiveStep } = useSteps({
|
||||
index: 0,
|
||||
count: steps.length,
|
||||
})
|
||||
const activeStepText = steps[activeStep].description
|
||||
|
||||
const memberTypeOptions = [
|
||||
{
|
||||
icon: FaIdBadge,
|
||||
value: 'user',
|
||||
label: 'User',
|
||||
description: 'A registered user on the XCS platform.'
|
||||
},
|
||||
{
|
||||
icon: SiRoblox,
|
||||
value: 'roblox',
|
||||
label: 'Roblox User',
|
||||
description: 'A Roblox user.'
|
||||
},
|
||||
{
|
||||
icon: SiRobloxstudio,
|
||||
value: 'roblox-group',
|
||||
label: 'Roblox Group',
|
||||
description: 'A Roblox group with roles (member, staff, etc.)'
|
||||
},
|
||||
{
|
||||
icon: AiFillIdcard,
|
||||
value: 'card',
|
||||
label: 'Card Numbers',
|
||||
description: 'A set of card numbers.'
|
||||
}
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (activeStep === 0) {
|
||||
// reset form
|
||||
const preserveType = formRef.current.values.type;
|
||||
formRef.current.resetForm();
|
||||
formRef.current.setFieldValue('type', preserveType);
|
||||
}
|
||||
}, [activeStep])
|
||||
|
||||
const { setValue: setRadioMemberType, getRadioProps: getRadioPropsMemberType } = useRadioGroup({
|
||||
name: 'memberType',
|
||||
defaultValue: 'user',
|
||||
onChange: (value) => {
|
||||
if (!formRef.current) return;
|
||||
formRef.current.setFieldValue('type', value)
|
||||
}
|
||||
})
|
||||
|
||||
const getUserSearchResults = useCallback(async (inputValue: string, callback: any) => {
|
||||
if (!inputValue) {
|
||||
callback([]);
|
||||
return;
|
||||
}
|
||||
await user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/platform/search-users/${encodeURIComponent(inputValue)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
callback(
|
||||
data.map((user: any) => ({
|
||||
label: `${user.displayName} (${user.username})`,
|
||||
value: user.id
|
||||
}))
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
// toast({
|
||||
// title: 'There was an error searching for users.',
|
||||
// description: error.message,
|
||||
// status: 'error',
|
||||
// duration: 5000,
|
||||
// isClosable: true
|
||||
// });
|
||||
});
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
const getGroupSearchResults = useCallback((value: string, callback: any) => {
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/roblox/group-search/${encodeURIComponent(value)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
let options = [] as any;
|
||||
data.forEach((group: any) => {
|
||||
options.push({
|
||||
label: group.name,
|
||||
value: group.id
|
||||
});
|
||||
});
|
||||
callback(options);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error fetching Roblox group search results.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
callback([]);
|
||||
});
|
||||
});
|
||||
}, [user, toast]);
|
||||
|
||||
const fetchGroupRoles = useCallback((groupId: any) => {
|
||||
groupId = groupId?.value;
|
||||
setGroupRoles([]);
|
||||
if (!groupId) return;
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/roblox/group-roles/${groupId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
setGroupRoles(
|
||||
data.map((role: any) => {
|
||||
return {
|
||||
label: role.name,
|
||||
value: role.id
|
||||
};
|
||||
})
|
||||
);
|
||||
if (lastGroupId !== groupId) {
|
||||
formRef.current.setFieldValue('robloxGroupRoles', []);
|
||||
}
|
||||
toast({
|
||||
title: 'Successfully fetched group roles.',
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
setLastGroupId(groupId);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error fetching Roblox group roles.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [user, toast, lastGroupId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
innerRef={formRef}
|
||||
enableReinitialize={true}
|
||||
initialValues={{
|
||||
type: 'user',
|
||||
name: '',
|
||||
role: { label: 'Member', value: 1 } as SelectOption,
|
||||
id: null as
|
||||
| SelectOption
|
||||
| any,
|
||||
username: '',
|
||||
accessGroups: [],
|
||||
cardNumbers: [] as SelectOption[],
|
||||
robloxGroupId: null as any,
|
||||
robloxGroupRoles: []
|
||||
}}
|
||||
onSubmit={(values, actions) => {
|
||||
let finalBody = {} as any;
|
||||
|
||||
switch (values.type) {
|
||||
case 'user':
|
||||
finalBody = {
|
||||
type: values.type,
|
||||
id: values.id?.value,
|
||||
role: values.role?.value,
|
||||
accessGroups: values?.accessGroups?.map((ag: any) => ag?.value)
|
||||
};
|
||||
break;
|
||||
case 'roblox':
|
||||
finalBody = {
|
||||
type: values.type,
|
||||
username: values.username,
|
||||
accessGroups: values?.accessGroups?.map((ag: any) => ag?.value)
|
||||
};
|
||||
break;
|
||||
case 'roblox-group':
|
||||
finalBody = {
|
||||
type: values.type,
|
||||
name: values.name,
|
||||
robloxGroupId: values.robloxGroupId?.value,
|
||||
robloxGroupRoles: values.robloxGroupRoles.map((r: any) => r.value),
|
||||
accessGroups: values?.accessGroups?.map((ag: any) => ag?.value)
|
||||
};
|
||||
break;
|
||||
case 'card':
|
||||
finalBody = {
|
||||
type: values.type,
|
||||
name: values.name,
|
||||
cardNumbers: values?.cardNumbers?.map((cn: any) => cn?.value),
|
||||
accessGroups: values?.accessGroups?.map((ag: any) => ag?.value)
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/organizations/${organization.id}/members`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(finalBody)
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
toast({
|
||||
title: data.message,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
onClose();
|
||||
onAdd();
|
||||
// actions.resetForm();
|
||||
// setActiveStep(0);
|
||||
// setRadioMemberType('user');
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error adding a member to your organization.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
allowPinchZoom
|
||||
closeOnOverlayClick={false}
|
||||
onCloseComplete={() => {
|
||||
props.resetForm();
|
||||
setActiveStep(0);
|
||||
setRadioMemberType('user');
|
||||
}}
|
||||
size={'lg'}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<Form>
|
||||
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<ModalHeader>
|
||||
Add Member
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody minH={'xl'}>
|
||||
<Stack gap={0} py={2}>
|
||||
<Stepper size='sm' index={activeStep} gap='0' colorScheme='black'>
|
||||
{steps.map((step, index) => (
|
||||
<Step as={chakra.div} key={index} gap={0}>
|
||||
<StepIndicator>
|
||||
<StepStatus complete={<StepIcon />} />
|
||||
</StepIndicator>
|
||||
<StepSeparator as={chakra.div} _horizontal={{ ml: '0' }} />
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
<Text fontSize={'xl'} pt={4}>
|
||||
Step {activeStep + 1}: <b>{activeStepText}</b>
|
||||
</Text>
|
||||
<Text variant={'subtext'} fontSize={'sm'}>
|
||||
{
|
||||
activeStep === 0 && (
|
||||
'Choose the type of member you want to add to your organization.'
|
||||
)
|
||||
}
|
||||
{
|
||||
activeStep === 1 && (
|
||||
'Enter the details for the member you want to add to your organization.'
|
||||
)
|
||||
}
|
||||
{
|
||||
activeStep === 2 && (
|
||||
'Review your member details before adding them to your organization.'
|
||||
)
|
||||
}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Flex py={4} w={'full'}>
|
||||
{
|
||||
activeStep === 0 && (
|
||||
<Field name="type">
|
||||
{({ field, form }: any) => (
|
||||
<Flex flexDir={'row'} gap={4} w={'full'}>
|
||||
<RadioGroup {...field} defaultValue="user" dir='column' value={field.value} w={'full'}>
|
||||
<Stack spacing={2}>
|
||||
{memberTypeOptions.map((value: any) => {
|
||||
const radio = getRadioPropsMemberType({ value: value.value })
|
||||
return (
|
||||
<RadioCard key={value.value} {...radio}>
|
||||
<Flex flexDir={'row'} align={'center'} justify={'flex-start'}>
|
||||
<Icon as={value.icon} w={5} h={5} />
|
||||
<Flex flexDir={'column'} ml={4}>
|
||||
<Text fontWeight={'bold'}>
|
||||
{value.label}
|
||||
</Text>
|
||||
<Text fontSize={'sm'} color={'gray.500'}>
|
||||
{value.description}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</RadioCard>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</Flex>
|
||||
)}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
{
|
||||
activeStep === 1 && (
|
||||
<Flex flexDir={'column'} gap={2} w={'full'}>
|
||||
{
|
||||
props.values.type && (
|
||||
<>
|
||||
{props.values.type === 'user' &&
|
||||
<Field name="id">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl minW={'196px'} w={'fit-content'}>
|
||||
<FormLabel>User</FormLabel>
|
||||
<AsyncSelect
|
||||
{...field}
|
||||
name="User"
|
||||
options={[]}
|
||||
placeholder="Search for a user..."
|
||||
isMulti={false}
|
||||
closeMenuOnSelect={true}
|
||||
isClearable={true}
|
||||
size="md"
|
||||
noOptionsMessage={() => 'No search results found.'}
|
||||
loadOptions={(inputValue, callback) => {
|
||||
getUserSearchResults(inputValue, callback);
|
||||
}}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('id', value);
|
||||
}}
|
||||
value={field.value || []}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Enter the user you want to add to your organization.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>}
|
||||
{props.values.type === 'roblox' &&
|
||||
<Field name="username">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
type={'username'}
|
||||
variant={'outline'}
|
||||
placeholder={'Roblox Username'}
|
||||
autoComplete={'off'}
|
||||
autoCorrect={'off'}
|
||||
spellCheck={'false'}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
}
|
||||
{
|
||||
props.values.type === 'roblox-group' &&
|
||||
<>
|
||||
<Field name="name">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
type={'username'}
|
||||
variant={'outline'}
|
||||
placeholder={'Name'}
|
||||
autoComplete={'off'}
|
||||
autoCorrect={'off'}
|
||||
spellCheck={'false'}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Enter the name you want to give to the group that's easy to distinguish.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Stack
|
||||
direction={{ base: 'column', md: 'column' }}
|
||||
w={'full'}
|
||||
>
|
||||
<Field name="robloxGroupId">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Group</FormLabel>
|
||||
<AsyncSelect
|
||||
{...field}
|
||||
name="robloxGroupId"
|
||||
ref={groupIdRef}
|
||||
options={[]}
|
||||
placeholder="Search for a group..."
|
||||
isMulti={false}
|
||||
closeMenuOnSelect={true}
|
||||
isClearable={true}
|
||||
size="md"
|
||||
noOptionsMessage={() => 'No search results found.'}
|
||||
loadOptions={(inputValue, callback) => {
|
||||
getGroupSearchResults(inputValue, callback);
|
||||
}}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('robloxGroupId', value);
|
||||
fetchGroupRoles(value);
|
||||
}}
|
||||
value={field.value || []}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="robloxGroupRoles">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Group Roles</FormLabel>
|
||||
<Select
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
options={groupRoles}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('robloxGroupRoles', value);
|
||||
}}
|
||||
value={field.value || []}
|
||||
placeholder="Select group roles..."
|
||||
isMulti
|
||||
closeMenuOnSelect={false}
|
||||
hideSelectedOptions={false}
|
||||
selectedOptionStyle="check"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</Stack>
|
||||
</>
|
||||
}
|
||||
{
|
||||
props.values.type === 'card' && (
|
||||
<>
|
||||
<Field name="name">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
type={'username'}
|
||||
variant={'outline'}
|
||||
placeholder={'Name'}
|
||||
autoComplete={'off'}
|
||||
autoCorrect={'off'}
|
||||
spellCheck={'false'}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Enter the name you want to give to the group that's easy to distinguish.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="cardNumbers">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Card Numbers</FormLabel>
|
||||
<CreatableSelect
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
options={[]}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('cardNumbers', value);
|
||||
}}
|
||||
value={field.value || []}
|
||||
placeholder="Enter card numbers..."
|
||||
isMulti
|
||||
closeMenuOnSelect={false}
|
||||
hideSelectedOptions={false}
|
||||
selectedOptionStyle="check"
|
||||
/>
|
||||
<FormHelperText>
|
||||
Enter the card numbers you want to add to your organization. <Text as={'strong'}>Ranges are supported (e.g. 1-24).</Text>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
props.values.type === 'user' && (
|
||||
<Field name="role">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Organization Role</FormLabel>
|
||||
<Select
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
options={[
|
||||
{ label: 'Member', value: 1 },
|
||||
{ label: 'Manager', value: 2 }
|
||||
]}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('role', value);
|
||||
}}
|
||||
value={field.value}
|
||||
placeholder="Select a role..."
|
||||
single={true}
|
||||
hideSelectedOptions={false}
|
||||
selectedOptionStyle={'check'}
|
||||
isDisabled={props.values.type !== 'user'}
|
||||
/>
|
||||
<FormHelperText>
|
||||
{
|
||||
props.values.type === 'user' ? (
|
||||
'Select the role you want to give to the user.'
|
||||
) : (
|
||||
'You cannot select a role for this member type.'
|
||||
)
|
||||
}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Field name="accessGroups">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Access Groups</FormLabel>
|
||||
<Select
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
options={accessGroupOptions}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('accessGroups', value);
|
||||
}}
|
||||
value={field.value || []}
|
||||
placeholder="Select an access group..."
|
||||
isMulti
|
||||
closeMenuOnSelect={false}
|
||||
hideSelectedOptions={false}
|
||||
selectedOptionStyle={'check'}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Select the access groups you want to give to this member type.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
{/* {
|
||||
activeStep === 2 && (
|
||||
<Flex flexDir={'column'} p={4} w={'full'} border={'1px solid'} borderColor={useColorModeValue('gray.200', 'gray.700')} borderRadius={'lg'}>
|
||||
<Heading as={'h3'} fontSize={'xl'} fontWeight={'bold'} pb={2}>
|
||||
Member Type
|
||||
</Heading>
|
||||
<Flex flexDir={'row'} gap={2} align={'center'}>
|
||||
<Icon as={memberTypeOptions.find((mto: any) => mto.value === props.values.type)?.icon} w={5} h={5} />
|
||||
<Flex flexDir={'column'} ml={3}>
|
||||
<Text fontWeight={'bold'}>
|
||||
{memberTypeOptions.find((mto: any) => mto.value === props.values.type)?.label}
|
||||
</Text>
|
||||
<Text fontSize={'sm'} color={'gray.500'}>
|
||||
{memberTypeOptions.find((mto: any) => mto.value === props.values.type)?.description}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Heading as={'h3'} fontSize={'xl'} fontWeight={'bold'} pt={4} pb={2}>
|
||||
Member Details
|
||||
</Heading>
|
||||
{
|
||||
props.values.type === 'user' && (
|
||||
<>
|
||||
<Flex flexDir={'row'} gap={2} align={'center'}>
|
||||
<Icon as={FaIdBadge} w={5} h={5} />
|
||||
<Flex flexDir={'column'} ml={3}>
|
||||
<Text fontWeight={'bold'}>
|
||||
{props.values.id?.label}
|
||||
</Text>
|
||||
<Text fontSize={'sm'} color={'gray.500'}>
|
||||
{props.values.id?.value}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Flex flexDir={'row'} gap={2} align={'center'} pt={2}>
|
||||
<Icon as={AiFillIdcard} w={5} h={5} />
|
||||
<Flex flexDir={'column'} ml={3}>
|
||||
<Text fontWeight={'bold'}>
|
||||
{props.values.role?.label}
|
||||
</Text>
|
||||
<Text fontSize={'sm'} color={'gray.500'}>
|
||||
{props.values.role?.value}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
props.values.type === 'roblox' && (
|
||||
<>
|
||||
<Flex flexDir={'row'} gap={2} align={'center'}>
|
||||
<Flex flexDir={'column'}>
|
||||
<Text variant={'subtext'}>Roblox Username</Text>
|
||||
<Text fontWeight={'bold'}>
|
||||
{props.values.username}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Flex>
|
||||
)
|
||||
} */}
|
||||
</Flex>
|
||||
|
||||
|
||||
{/* <VStack spacing={2}> */}
|
||||
{/* <Field name="username">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
type={'username'}
|
||||
variant={'outline'}
|
||||
placeholder={'Roblox Username'}
|
||||
autoComplete={'off'}
|
||||
autoCorrect={'off'}
|
||||
spellCheck={'false'}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="accessGroups">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Access Groups</FormLabel>
|
||||
<Select
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
options={accessGroupOptions}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('accessGroups', value);
|
||||
}}
|
||||
value={field.value || []}
|
||||
placeholder="Select an access group..."
|
||||
isMulti
|
||||
closeMenuOnSelect={false}
|
||||
hideSelectedOptions={false}
|
||||
selectedOptionStyle={'check'}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Add a Roblox user that isn't registered on XCS to your organization.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field> */}
|
||||
{/* </VStack> */}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter gap={4}>
|
||||
<Button
|
||||
onClick={() => { setActiveStep(activeStep - 1) }}
|
||||
isDisabled={activeStep === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Spacer />
|
||||
{activeStep === 0 ? (
|
||||
<Button
|
||||
onClick={() => { setActiveStep(activeStep + 1) }}
|
||||
isDisabled={activeStep === steps.length - 1}
|
||||
colorScheme='black'
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={props.isSubmitting}
|
||||
colorScheme={'black'}
|
||||
isDisabled={
|
||||
(props.values.type === 'user' && (!props.values.id || !props.values.role)) ||
|
||||
(props.values.type === 'roblox' && !props.values.username) ||
|
||||
(props.values.type === 'roblox-group' && (!props.values.name || !props.values.robloxGroupId || !props.values.robloxGroupRoles)) ||
|
||||
(props.values.type === 'card' && (!props.values.name || !props.values.cardNumbers))
|
||||
}
|
||||
>
|
||||
{(props.values.type === 'user') ? "Invite" : "Add"} Member
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik >
|
||||
</>
|
||||
);
|
||||
}
|
227
src/components/InviteOrganizationModal.tsx
Normal file
|
@ -0,0 +1,227 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
VStack,
|
||||
useClipboard,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { Select } from 'chakra-react-select';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
|
||||
export default function InviteOrganizationModal({
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
onCreate,
|
||||
organizationId
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onCreate: (location: any) => void;
|
||||
organizationId?: string;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const { user } = useAuthContext();
|
||||
const [inviteCode, setInviteCode] = useState<string | null>(null);
|
||||
const { setValue: setClipboardValue, onCopy: onClipboardCopy, hasCopied: clipboardHasCopied } = useClipboard('');
|
||||
|
||||
const onModalClose = () => {
|
||||
onClose();
|
||||
setInviteCode(null);
|
||||
};
|
||||
|
||||
const copyInviteLink = () => {
|
||||
onClipboardCopy();
|
||||
toast({
|
||||
title: 'Copied invite link to clipboard!',
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={{ role: { label: 'Member', value: 1 }, singleUse: true }}
|
||||
onSubmit={(values, actions) => {
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/organizations/${organizationId}/invitations/links`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
singleUse: values.singleUse,
|
||||
role: values.role.value
|
||||
})
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
toast({
|
||||
title: data.message,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
setInviteCode(data.inviteCode);
|
||||
setClipboardValue(`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/${data.inviteCode}`);
|
||||
actions.resetForm();
|
||||
onCreate(data.inviteCode);
|
||||
// onClose();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error creating the invitation.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onModalClose}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<Form>
|
||||
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<ModalHeader pb={2}>Create Invitation Link</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={4}>
|
||||
<VStack spacing={2}>
|
||||
{!inviteCode ? (
|
||||
<>
|
||||
<Field name="role">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
options={[
|
||||
{ label: 'Member', value: 1 },
|
||||
{ label: 'Manager', value: 2 }
|
||||
]}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('role', value);
|
||||
}}
|
||||
value={field.value}
|
||||
placeholder="Select a role..."
|
||||
single={true}
|
||||
hideSelectedOptions={false}
|
||||
selectedOptionStyle={'check'}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
name="singleUse"
|
||||
type={'checkbox'}
|
||||
>
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
isChecked={field.value}
|
||||
>
|
||||
Single Use
|
||||
</Checkbox>
|
||||
<FormHelperText>
|
||||
All invitation links expire after 14 days.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormControl>
|
||||
<FormLabel>Invitation Link</FormLabel>
|
||||
<Input
|
||||
variant={'outline'}
|
||||
value={`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/${inviteCode}`}
|
||||
isReadOnly={true}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Share this link with the person you want to invite to your organization.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{!inviteCode ? (
|
||||
<HStack>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
isLoading={props.isSubmitting}
|
||||
type={'submit'}
|
||||
>
|
||||
Create Link
|
||||
</Button>
|
||||
<Button onClick={onModalClose}>Cancel</Button>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
onClick={copyInviteLink}
|
||||
>
|
||||
{!clipboardHasCopied ? 'Copy Link' : 'Copied!'}
|
||||
</Button>
|
||||
<Button onClick={onModalClose}>Close</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
354
src/components/InviteOrganizationRobloxGroupModal.tsx
Normal file
|
@ -0,0 +1,354 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuDivider,
|
||||
MenuGroup,
|
||||
MenuItem,
|
||||
MenuItemOption,
|
||||
MenuList,
|
||||
MenuOptionGroup,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { AiFillCheckCircle } from 'react-icons/ai';
|
||||
|
||||
import { AccessGroup, Organization } from '@/types';
|
||||
import { AsyncSelect, CreatableSelect, Select } from 'chakra-react-select';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
|
||||
export default function InviteOrganizationRobloxGroupModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAdd,
|
||||
organization,
|
||||
accessGroupOptions
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: () => void;
|
||||
organization: any;
|
||||
accessGroupOptions: any;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const initialRef = useRef(null);
|
||||
const finalRef = useRef(null);
|
||||
const { user } = useAuthContext();
|
||||
|
||||
const [groupRoles, setGroupRoles] = useState<any>([]);
|
||||
const [groupSearchResults, setGroupSearchResults] = useState<any>([]);
|
||||
const [lastGroupId, setLastGroupId] = useState<any>('');
|
||||
const groupIdRef = useRef<any>(null);
|
||||
const groupRolesRef = useRef<any>(null);
|
||||
const groupNameRef = useRef<any>(null);
|
||||
const formRef = useRef<any>(null);
|
||||
|
||||
const getGroupSearchResults = (value: string, callback: any) => {
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/roblox/group-search/${encodeURIComponent(value)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
let options = [] as any;
|
||||
data.forEach((group: any) => {
|
||||
options.push({
|
||||
label: group.name,
|
||||
value: group.id
|
||||
});
|
||||
});
|
||||
callback(options);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error fetching Roblox group search results.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
callback([]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const fetchGroupRoles = (groupId: any) => {
|
||||
groupId = groupId?.value;
|
||||
setGroupRoles([]);
|
||||
if (!groupId) return;
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/roblox/group-roles/${groupId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
setGroupRoles(
|
||||
data.map((role: any) => {
|
||||
return {
|
||||
label: role.name,
|
||||
value: role.id
|
||||
};
|
||||
})
|
||||
);
|
||||
if (lastGroupId !== groupId) {
|
||||
formRef.current.setFieldValue('robloxGroupRoles', []);
|
||||
}
|
||||
toast({
|
||||
title: 'Successfully fetched group roles.',
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
setLastGroupId(groupId);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error fetching Roblox group roles.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
innerRef={formRef}
|
||||
enableReinitialize={true}
|
||||
initialValues={{
|
||||
name: '',
|
||||
robloxGroupId: '' as any,
|
||||
robloxGroupRoles: [],
|
||||
accessGroups: []
|
||||
}}
|
||||
onSubmit={(values, actions) => {
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/organizations/${organization.id}/members`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'roblox-group',
|
||||
name: values.name || values.robloxGroupId?.label || 'Roblox Group',
|
||||
robloxGroupId: values.robloxGroupId?.value,
|
||||
robloxGroupRoles: values.robloxGroupRoles?.map((role: any) => role.value),
|
||||
// get access group ids from names
|
||||
accessGroups: values?.accessGroups.map((accessGroup: any) => accessGroup.value)
|
||||
})
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
toast({
|
||||
title: data.message,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
onClose();
|
||||
onAdd();
|
||||
actions.resetForm();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error adding a Roblox group to your organization.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
allowPinchZoom
|
||||
>
|
||||
<ModalOverlay />
|
||||
<Form>
|
||||
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<ModalHeader pb={2}>Add Roblox Group</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={4}>
|
||||
<VStack spacing={2}>
|
||||
<Field name="name">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
type={'username'}
|
||||
variant={'outline'}
|
||||
placeholder={'Name'}
|
||||
autoComplete={'off'}
|
||||
autoCorrect={'off'}
|
||||
spellCheck={'false'}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Stack
|
||||
direction={{ base: 'column', md: 'column' }}
|
||||
w={'full'}
|
||||
>
|
||||
<Field name="robloxGroupId">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Group</FormLabel>
|
||||
<AsyncSelect
|
||||
{...field}
|
||||
name="robloxGroupId"
|
||||
ref={groupIdRef}
|
||||
options={[]}
|
||||
placeholder="Search for a group..."
|
||||
isMulti={false}
|
||||
closeMenuOnSelect={true}
|
||||
isClearable={true}
|
||||
size="md"
|
||||
noOptionsMessage={() => 'No search results found.'}
|
||||
loadOptions={(inputValue, callback) => {
|
||||
getGroupSearchResults(inputValue, callback);
|
||||
}}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('robloxGroupId', value);
|
||||
fetchGroupRoles(value);
|
||||
}}
|
||||
value={field.value || []}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="robloxGroupRoles">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Group Roles</FormLabel>
|
||||
<Select
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
options={groupRoles}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('robloxGroupRoles', value);
|
||||
}}
|
||||
value={field.value || []}
|
||||
placeholder="Select group roles..."
|
||||
isMulti
|
||||
closeMenuOnSelect={false}
|
||||
hideSelectedOptions={false}
|
||||
selectedOptionStyle="check"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</Stack>
|
||||
<Field name="accessGroups">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Access Groups</FormLabel>
|
||||
<Select
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
options={accessGroupOptions}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('accessGroups', value);
|
||||
}}
|
||||
value={field.value || []}
|
||||
placeholder="Select an access group..."
|
||||
isMulti
|
||||
closeMenuOnSelect={false}
|
||||
hideSelectedOptions={false}
|
||||
selectedOptionStyle={'check'}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Add a Roblox group to your organization to start managing their access.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
mr={3}
|
||||
isLoading={props.isSubmitting}
|
||||
type={'submit'}
|
||||
>
|
||||
Add Roblox Group
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
240
src/components/InviteOrganizationRobloxModal.tsx
Normal file
|
@ -0,0 +1,240 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Text,
|
||||
Textarea,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { AccessGroup, Organization } from '@/types';
|
||||
import { AsyncSelect, CreatableSelect, Select } from 'chakra-react-select';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
|
||||
import { agKV, agNames, roleToText, textToRole } from '@/lib/utils';
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
|
||||
export default function InviteOrganizationRobloxModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAdd,
|
||||
organization,
|
||||
accessGroupOptions
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: () => void;
|
||||
organization: any;
|
||||
accessGroupOptions: any;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const initialRef = useRef(null);
|
||||
const finalRef = useRef(null);
|
||||
const { user } = useAuthContext();
|
||||
|
||||
const getAccessGroupType = (ag: AccessGroup) => {
|
||||
if (ag.type === 'organization') {
|
||||
return 'Organization';
|
||||
} else if (ag.type === 'location') {
|
||||
// TODO: get location name
|
||||
return ag.locationName || ag.locationId || 'Unknown';
|
||||
} else {
|
||||
return ag.type;
|
||||
}
|
||||
};
|
||||
|
||||
const getAccessGroupOptions = useCallback(
|
||||
(organization: Organization) => {
|
||||
if (!organization) return [];
|
||||
const ags = Object.values(organization?.accessGroups) || [];
|
||||
interface Group {
|
||||
label: string;
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
let groups = [] as any;
|
||||
|
||||
ags.forEach((ag: AccessGroup) => {
|
||||
// check if the group is already in the groups object
|
||||
if (groups.find((g: Group) => g.label === getAccessGroupType(ag))) {
|
||||
// if it is, add the option to the options array
|
||||
groups
|
||||
.find((g: Group) => g.label === getAccessGroupType(ag))
|
||||
.options.push({
|
||||
label: ag.name,
|
||||
value: ag.id
|
||||
});
|
||||
} else {
|
||||
// if it's not, add the group to the groups array
|
||||
groups.push({
|
||||
label: getAccessGroupType(ag),
|
||||
options: [
|
||||
{
|
||||
label: ag.name,
|
||||
value: ag.id
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// sort the groups so organizations are at the bottom
|
||||
groups.sort((a: Group, b: Group) => {
|
||||
if (a.label === 'Organization') return 1;
|
||||
if (b.label === 'Organization') return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return groups;
|
||||
},
|
||||
[organization]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={{ username: '', accessGroups: [] }}
|
||||
onSubmit={(values, actions) => {
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/organizations/${organization.id}/members`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'roblox',
|
||||
username: values.username,
|
||||
|
||||
accessGroups: values?.accessGroups?.map((ag: any) => ag?.value)
|
||||
})
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
toast({
|
||||
title: data.message,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
onClose();
|
||||
onAdd();
|
||||
actions.resetForm();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error adding a Roblox user to your organization.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
allowPinchZoom
|
||||
>
|
||||
<ModalOverlay />
|
||||
<Form>
|
||||
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<ModalHeader pb={2}>Add Roblox Member</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={4}>
|
||||
<VStack spacing={2}>
|
||||
<Field name="username">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
type={'username'}
|
||||
variant={'outline'}
|
||||
placeholder={'Roblox Username'}
|
||||
autoComplete={'off'}
|
||||
autoCorrect={'off'}
|
||||
spellCheck={'false'}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="accessGroups">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Access Groups</FormLabel>
|
||||
<Select
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
options={accessGroupOptions}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('accessGroups', value);
|
||||
}}
|
||||
value={field.value || []}
|
||||
placeholder="Select an access group..."
|
||||
isMulti
|
||||
closeMenuOnSelect={false}
|
||||
hideSelectedOptions={false}
|
||||
selectedOptionStyle={'check'}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Add a Roblox user that isn't registered on XCS to your organization.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
mr={3}
|
||||
isLoading={props.isSubmitting}
|
||||
type={'submit'}
|
||||
>
|
||||
Add Roblox User
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
365
src/components/InvitePlatformModal.tsx
Normal file
|
@ -0,0 +1,365 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Input,
|
||||
InputGroup,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
NumberDecrementStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
VStack,
|
||||
useClipboard,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { AsyncSelect } from 'chakra-react-select';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
|
||||
export default function InvitePlatformModal({
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
onCreate
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onCreate: (location: any) => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const { currentUser, user } = useAuthContext();
|
||||
const [inviteCode, setInviteCode] = useState<string | null>(null);
|
||||
const senderRef = useRef<any>(null);
|
||||
const { setValue: setClipboardValue, onCopy: onClipboardCopy, hasCopied: clipboardHasCopied } = useClipboard('');
|
||||
|
||||
const onModalClose = () => {
|
||||
onClose();
|
||||
setInviteCode(null);
|
||||
};
|
||||
|
||||
const copyInviteLink = () => {
|
||||
onClipboardCopy();
|
||||
toast({
|
||||
title: 'Copied invite link to clipboard!',
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
};
|
||||
|
||||
const getUserSearchResults = async (inputValue: string, callback: any) => {
|
||||
if (!inputValue) {
|
||||
callback([]);
|
||||
return;
|
||||
}
|
||||
await user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/admin/search-users/${encodeURIComponent(inputValue)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
callback(
|
||||
data.map((user: any) => ({
|
||||
label: `${user.displayName} (${user.username})`,
|
||||
value: user.id
|
||||
}))
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error searching for users.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={{
|
||||
code: '',
|
||||
senderId: null as
|
||||
| {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
| any,
|
||||
maxUses: 1,
|
||||
referrals: 0,
|
||||
comment: ''
|
||||
}}
|
||||
onSubmit={(values, actions) => {
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/admin/invite-link`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
maxUses: values.maxUses,
|
||||
code: values.code,
|
||||
senderId: values.senderId?.value,
|
||||
referrals: values.referrals || 0,
|
||||
comment: values.comment
|
||||
})
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
toast({
|
||||
title: data.message,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
setInviteCode(data.code);
|
||||
setClipboardValue(`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/${data.code}`);
|
||||
actions.resetForm();
|
||||
onCreate(data.code);
|
||||
// onClose();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error creating the invitation.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onModalClose}
|
||||
isCentered
|
||||
onCloseComplete={() => {
|
||||
props.setFieldValue('code', '');
|
||||
props.setFieldValue('senderId', null);
|
||||
}}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<Form>
|
||||
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<ModalHeader pb={2}>Create Invitation Link</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={4}>
|
||||
<VStack spacing={2}>
|
||||
{!inviteCode ? (
|
||||
<>
|
||||
<InputGroup gap={4}>
|
||||
<Field name="code">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Custom Invite Code</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
autoComplete="off"
|
||||
placeholder="Custom Invite Code (optional)"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="maxUses">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl w={'fit-content'}>
|
||||
<FormLabel>Maximum Uses</FormLabel>
|
||||
<InputGroup>
|
||||
<NumberInput
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Maximum Uses"
|
||||
variant={'outline'}
|
||||
min={1}
|
||||
max={100}
|
||||
defaultValue={1}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('maxUses', value);
|
||||
}}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</InputGroup>
|
||||
<Field name="senderId">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Sender</FormLabel>
|
||||
<AsyncSelect
|
||||
{...field}
|
||||
name="senderId"
|
||||
ref={senderRef}
|
||||
options={[]}
|
||||
placeholder="Search for a user... (optional)"
|
||||
isMulti={false}
|
||||
closeMenuOnSelect={true}
|
||||
isClearable={true}
|
||||
size="md"
|
||||
noOptionsMessage={() => 'No search results found.'}
|
||||
loadOptions={(inputValue, callback) => {
|
||||
getUserSearchResults(inputValue, callback);
|
||||
}}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('senderId', value);
|
||||
}}
|
||||
value={field.value || []}
|
||||
selectedOptionStyle='check'
|
||||
/>
|
||||
<FormHelperText>If left blank, you will be the sender.</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="referrals">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl w={'fit-content'} alignSelf={'flex-start'}>
|
||||
<FormLabel>Starting Referrals</FormLabel>
|
||||
<InputGroup>
|
||||
<NumberInput
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
placeholder="Starting Referrals"
|
||||
variant={'outline'}
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={0}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue('referrals', value);
|
||||
}}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</InputGroup>
|
||||
<FormHelperText>
|
||||
If you want to give the user referrals to invite others, enter the number of referrals you want to give them here.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="comment">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Comment</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
autoComplete="off"
|
||||
placeholder="Comment"
|
||||
/>
|
||||
<FormHelperText>
|
||||
All invitation links expire after 14 days.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormControl>
|
||||
<FormLabel>Invitation Link</FormLabel>
|
||||
<Input
|
||||
variant={'outline'}
|
||||
value={`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/${inviteCode}`}
|
||||
isReadOnly={true}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Share this link with the people you want to invite to the platform.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{!inviteCode ? (
|
||||
<HStack>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
isLoading={props.isSubmitting}
|
||||
type={'submit'}
|
||||
>
|
||||
Create Link
|
||||
</Button>
|
||||
<Button onClick={onModalClose}>Cancel</Button>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
onClick={copyInviteLink}
|
||||
>
|
||||
{!clipboardHasCopied ? 'Copy Link' : 'Copied!'}
|
||||
</Button>
|
||||
<Button onClick={onModalClose}>Close</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
195
src/components/InvitePlatformUserModal.tsx
Normal file
|
@ -0,0 +1,195 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Input,
|
||||
InputGroup,
|
||||
Link,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
NumberDecrementStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
VStack,
|
||||
useClipboard,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { AsyncSelect, CreatableSelect, Select } from 'chakra-react-select';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import NextLink from 'next/link';
|
||||
|
||||
import { textToRole } from '@/lib/utils';
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
|
||||
export default function InvitePlatformUserModal({
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
onCreate
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onCreate: (location: any) => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const { currentUser, user } = useAuthContext();
|
||||
const [inviteCode, setInviteCode] = useState<string | null>(null);
|
||||
const senderRef = useRef<any>(null);
|
||||
const { setValue: setClipboardValue, onCopy: onClipboardCopy, hasCopied: clipboardHasCopied } = useClipboard('');
|
||||
|
||||
const onModalClose = () => {
|
||||
onClose();
|
||||
setInviteCode(null);
|
||||
};
|
||||
|
||||
const copyInviteLink = () => {
|
||||
onClipboardCopy();
|
||||
toast({
|
||||
title: 'Copied invite link to clipboard!',
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
enableReinitialize={true}
|
||||
initialValues={{}}
|
||||
onSubmit={(values, actions) => {
|
||||
user.getIdToken().then((token: any) => {
|
||||
fetch(`/api/v1/me/referrals`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
toast({
|
||||
title: data.message,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
setInviteCode(data.code);
|
||||
setClipboardValue(`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/${data.code}`);
|
||||
actions.resetForm();
|
||||
onCreate(data.code);
|
||||
// onClose();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error creating the invitation.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onModalClose}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<Form>
|
||||
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<ModalHeader pb={2}>Sponsor a User</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={4}>
|
||||
<VStack spacing={2}>
|
||||
{!inviteCode ? (
|
||||
<>
|
||||
<Text>
|
||||
Create an invitation link to invite people to join the platform.
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormControl>
|
||||
<FormLabel>Invitation Link</FormLabel>
|
||||
<Input
|
||||
variant={'outline'}
|
||||
value={`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/${inviteCode}`}
|
||||
isReadOnly={true}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Share this link with the people you want to invite to the platform.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{!inviteCode ? (
|
||||
<HStack>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
isLoading={props.isSubmitting}
|
||||
type={'submit'}
|
||||
>
|
||||
Create Link
|
||||
</Button>
|
||||
<Button onClick={onModalClose}>Cancel</Button>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
onClick={copyInviteLink}
|
||||
>
|
||||
{!clipboardHasCopied ? 'Copy Link' : 'Copied!'}
|
||||
</Button>
|
||||
<Button onClick={onModalClose}>Close</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
143
src/components/JoinOrganizationDialog.tsx
Normal file
|
@ -0,0 +1,143 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useRef } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
|
||||
export default function JoinOrganizationDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onJoin,
|
||||
initialValue = ''
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onJoin: (organization: any) => void;
|
||||
initialValue?: string;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const initialRef = useRef(null);
|
||||
const finalRef = useRef(null);
|
||||
const { user } = useAuthContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Formik
|
||||
initialValues={{ inviteCode: initialValue }}
|
||||
onSubmit={async (values, actions) => {
|
||||
// Handle Links
|
||||
values.inviteCode = values.inviteCode.replace(`${process.env.NEXT_PUBLIC_ROOT_URL}/invitation/`, '');
|
||||
|
||||
await user.getIdToken().then(async (token: string) => {
|
||||
await fetch(`/api/v1/organizations/join`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: values.inviteCode as string
|
||||
})
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
return res.json();
|
||||
} else {
|
||||
return res.json().then((json) => {
|
||||
throw new Error(json.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then((data) => {
|
||||
toast({
|
||||
title: data.message,
|
||||
status: 'success',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
onClose();
|
||||
onJoin(data.organizationId);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({
|
||||
title: 'There was an error joining the organization.',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
actions.setSubmitting(false);
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
initialFocusRef={initialRef}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<Form>
|
||||
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<ModalHeader>Join Organization</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={4}>
|
||||
<Field name="inviteCode">
|
||||
{({ field, form }: any) => (
|
||||
<FormControl>
|
||||
<FormLabel>Invite Code</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
variant={'outline'}
|
||||
placeholder={'Invite Code'}
|
||||
ref={initialRef}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Received an invite code? Enter it here to join an organization.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
colorScheme={'black'}
|
||||
mr={3}
|
||||
isLoading={props.isSubmitting}
|
||||
type={'submit'}
|
||||
>
|
||||
Join
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
1066
src/components/MemberEditModal.tsx
Normal file
257
src/components/OrganizationInvitationsModal.tsx
Normal file
|
@ -0,0 +1,257 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Flex,
|
||||
Icon,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Text,
|
||||
Th,
|
||||
Thead,
|
||||
Tooltip,
|
||||
Tr,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { Link } from '@chakra-ui/next-js';
|
||||
|
||||
import { useAuthContext } from '@/contexts/AuthContext';
|
||||
import { Organization, OrganizationInvitation } from '@/types';
|
||||
import { useRouter } from 'next/router';
|
||||
import { BiRefresh } from 'react-icons/bi';
|
||||
|
||||
const moment = require('moment');
|
||||
|
||||
function TableEntry({ key, invitation, skeleton, action }: { key: number | string, invitation?: OrganizationInvitation, skeleton?: boolean, action: any }) {
|
||||
const toRelativeTime = useMemo(() => (date: any) => {
|
||||
return moment(new Date(date)).fromNow();
|
||||
}, []);
|
||||
|
||||
return <>
|
||||
<Tr key={key}>
|
||||
<Td>
|
||||
<Skeleton isLoaded={!skeleton}>
|
||||
<Flex align={'center'} gap={4}>
|
||||
<Avatar as={Link} href={`/@${invitation?.recipient?.username}`} target='_blank' transition={'opacity 0.2s ease-out'} _hover={{ opacity: 0.75 }} _active={{ opacity: 0.5 }} size={'md'} src={invitation?.recipient?.avatar || '/images/default-avatar.png'} />
|
||||
<Flex flexDir={'column'} justify={'center'}>
|
||||
<Skeleton isLoaded={!skeleton}>
|
||||
<Text fontWeight={'bold'}>
|
||||
{!skeleton ? invitation?.recipient?.displayName : "N/A"}
|
||||
</Text>
|
||||
</Skeleton>
|
||||
<Skeleton isLoaded={!skeleton}>
|
||||
<Text
|
||||
variant={'subtext'}
|
||||
color="gray.500"
|
||||
>
|
||||
@{invitation?.recipient?.username}
|
||||
</Text>
|
||||
</Skeleton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Skeleton>
|
||||
</Td>
|
||||
<Td>
|
||||
<Skeleton isLoaded={!skeleton}>
|
||||
<Text>
|
||||
{!skeleton ? toRelativeTime(invitation?.createdAt) : 'N/A'}
|
||||
</Text>
|
||||
</Skeleton>
|
||||
</Td>
|
||||
<Td>
|
||||
<Skeleton isLoaded={!skeleton}>
|
||||
<Flex align={'center'} gap={2}>
|
||||
<Avatar as={Link} href={`/@${invitation?.createdBy?.username}`} target='_blank' transition={'opacity 0.2s ease-out'} _hover={{ opacity: 0.75 }} _active={{ opacity: 0.5 }} size={'sm'} mr={2} src={invitation?.createdBy?.avatar || '/images/default-avatar.png'} />
|
||||
<Flex flexDir={'column'} justify={'center'}>
|
||||
<Text fontWeight={'bold'}>
|
||||
{!skeleton ? invitation?.createdBy?.displayName : "N/A"}
|
||||
</Text>
|
||||
<Text
|
||||
variant={'subtext'}
|
||||
color="gray.500"
|
||||
>
|
||||
@{invitation?.createdBy?.username}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Skeleton>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<Skeleton isLoaded={!skeleton}>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
onClick={() => { action(invitation, 'withdraw') }}
|
||||
size={"sm"}
|
||||
variant={"solid"}
|
||||
colorScheme='red'
|
||||
textDecor={"unset !important"}
|
||||
>
|
||||
Withdraw
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Skeleton>
|
||||
</Td>
|
||||
</Tr>
|
||||
</>
|
||||
}
|
||||
|
||||
export default function OrganizationInvitationsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
organization,
|
||||
onRefresh
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
organization?: Organization | null;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const { push } = useRouter();
|
||||
const { user } = useAuthContext();
|
||||
const [invitationsLoading, setInvitationsLoading] = useState(true);
|
||||
const [invitations, setInvitations] = useState<OrganizationInvitation[]>([]);
|
||||
|
||||
const fetchInvitations = useCallback(async () => {
|
||||
setInvitationsLoading(true);
|
||||
user.getIdToken().then(async (token: string) => {
|
||||
await fetch(`/api/v1/organizations/${organization?.id}/invitations`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}).then(async (res) => {
|
||||
const data = await res.json();
|
||||
if (res.status === 200) {
|
||||
setInvitations(data || []);
|
||||
} else {
|
||||
toast({
|
||||
title: "There was an error fetching the organization's invitations.",
|
||||
description: data.message,
|
||||
status: "error",
|
||||
duration: 9000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}).finally(() => {
|
||||
setInvitationsLoading(false);
|
||||
});
|
||||
});
|
||||
}, [user, toast, organization]);
|
||||
|
||||
const actOnInvitation = useCallback(async (invitation: OrganizationInvitation, action: 'withdraw') => {
|
||||
setInvitationsLoading(true);
|
||||
await user.getIdToken().then(async (token: string) => {
|
||||
await fetch(`/api/v1/organizations/${organization?.id}/invitations/${invitation.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}).then(async (res) => {
|
||||
const data = await res.json();
|
||||
if (res.status === 200) {
|
||||
await fetchInvitations();
|
||||
toast({
|
||||
title: data.message,
|
||||
status: "success",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "There was an error taking action on an invitation.",
|
||||
description: data.message,
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}).finally(() => {
|
||||
setInvitationsLoading(false);
|
||||
});
|
||||
});
|
||||
}, [user, toast, fetchInvitations, organization?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (!organization) return;
|
||||
if (!isOpen) return;
|
||||
fetchInvitations();
|
||||
}, [user, fetchInvitations, organization, isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
isCentered
|
||||
size={'4xl'}
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent bg={useColorModeValue('white', 'gray.800')}>
|
||||
<ModalHeader pb={2}>Invitations</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={4} minH={'xl'}>
|
||||
<Flex pb={4}>
|
||||
<Tooltip label={'Refresh'} placement={'top'}>
|
||||
<IconButton ml={'auto'} onClick={fetchInvitations} aria-label={'Refresh'} icon={<Icon as={BiRefresh} />} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<TableContainer overflow={'auto'} maxH={'md'}>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Recipient</Th>
|
||||
<Th>Invite Date</Th>
|
||||
<Th>Inviter</Th>
|
||||
<Th isNumeric>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{
|
||||
invitationsLoading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<TableEntry key={i} invitation={undefined} skeleton={true} action={actOnInvitation} />
|
||||
))
|
||||
) : (invitations.map((invitation: OrganizationInvitation) => (
|
||||
<TableEntry key={invitation.id as string} invitation={invitation} skeleton={false} action={actOnInvitation} />
|
||||
)))
|
||||
}
|
||||
</Tbody>
|
||||
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{
|
||||
!invitationsLoading && invitations.length === 0 && (
|
||||
<Text py={8} w={'full'} textAlign={'center'} color={'gray.500'}>
|
||||
There are no outgoing invitations.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
20
src/components/PageProgress.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
// Chakra UI
|
||||
import { useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
// Components
|
||||
import NextNProgress from 'nextjs-progressbar';
|
||||
|
||||
export default function PageProgress() {
|
||||
return (
|
||||
<NextNProgress
|
||||
color={useColorModeValue('#000', '#fff')}
|
||||
startPosition={0}
|
||||
// stopDelayMs={500}
|
||||
height={2}
|
||||
options={{
|
||||
showSpinner: false
|
||||
}}
|
||||
showOnShallow={false}
|
||||
/>
|
||||
);
|
||||
}
|
78
src/components/PlatformAlert.tsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
Box,
|
||||
Button,
|
||||
CloseButton,
|
||||
Flex,
|
||||
Stack,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
export default function PlatformAlert({
|
||||
status = 'error',
|
||||
title,
|
||||
description,
|
||||
isClosable = true,
|
||||
button
|
||||
}: {
|
||||
status?: 'error' | 'info' | 'success' | 'warning';
|
||||
title: string;
|
||||
description?: string;
|
||||
isClosable: boolean;
|
||||
button?: {
|
||||
isLoading?: boolean;
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}) {
|
||||
const { isOpen: isVisible, onClose, onOpen } = useDisclosure({ defaultIsOpen: true });
|
||||
|
||||
return (
|
||||
isVisible && (
|
||||
<>
|
||||
<Alert
|
||||
status={status}
|
||||
backdropFilter={'blur(24px)'}
|
||||
>
|
||||
<AlertIcon mx={2} />
|
||||
<Stack
|
||||
pl={{ base: 0, md: 2 }}
|
||||
direction={['column', 'row']}
|
||||
align={'center'}
|
||||
justify={'space-between'}
|
||||
w={'full'}
|
||||
>
|
||||
<Box>
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription>{description}</AlertDescription>
|
||||
</Box>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
h={'full'}
|
||||
>
|
||||
{button && (
|
||||
<Button
|
||||
onClick={button.onClick}
|
||||
variant={'solid'}
|
||||
size={'sm'}
|
||||
isLoading={button.isLoading}
|
||||
>
|
||||
{button.text}
|
||||
</Button>
|
||||
)}
|
||||
{isClosable && (
|
||||
<CloseButton
|
||||
onClick={onClose}
|
||||
position={'relative'}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|