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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|