mirror of
https://github.com/jambonz/jambonz-webapp.git
synced 2025-12-19 05:37:43 +00:00
feat: add login with google and github (#278)
* feat: add login with google and github * feat: hosted version * add register pages * feat: add verify email code page * register by email * fix login * fix logout * add all stripe pages * subscription delete account * fix edit account and edit sip realm * when user account login, remove cancel in edit page * remove name * update .env configuraiton vars
This commit is contained in:
16
.env
16
.env
@@ -11,4 +11,18 @@ VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
|
||||
## disables Jaeger Tracing feature
|
||||
#VITE_APP_JAEGER_TRACING_DISABLED=true
|
||||
## enable record All Calls feature
|
||||
#VITE_APP_DISABLE_CALL_RECORDING=true
|
||||
#VITE_APP_DISABLE_CALL_RECORDING=true
|
||||
## enable Forgot password
|
||||
#VITE_APP_ENABLE_FORGOT_PASSWORD=true
|
||||
## enable hosted system
|
||||
#VITE_APP_ENABLE_HOSTED_SYSTEM=true
|
||||
## Google Client ID
|
||||
#VITE_APP_GOOGLE_CLIENT_ID=
|
||||
## Github Client ID
|
||||
#VITE_APP_GITHUB_CLIENT_ID=
|
||||
## Default jambonz service provider SID
|
||||
#VITE_APP_DEFAULT_SERVICE_PROVIDER_SID=
|
||||
## Base url for jambomz webapp
|
||||
#VITE_APP_BASE_URL="http://jambonz.one"
|
||||
## Strip publishable key
|
||||
#VITE_APP_STRIPE_PUBLISHABLE_KEY="pk_test_EChRaX9Tjk8csZZVSeoGqNvu00lsJzjaU1"
|
||||
33
package-lock.json
generated
33
package-lock.json
generated
@@ -11,6 +11,8 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jambonz/ui-kit": "^0.0.21",
|
||||
"@stripe/react-stripe-js": "^2.1.1",
|
||||
"@stripe/stripe-js": "^1.54.1",
|
||||
"dayjs": "^1.11.5",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"react": "^18.0.0",
|
||||
@@ -831,6 +833,24 @@
|
||||
"node": ">= 8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/react-stripe-js": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.1.1.tgz",
|
||||
"integrity": "sha512-aHm6cWOqmOyq3eKKlR42hgvrcZhzc9ijL+BeXJ5H3uaWUKKcooSsBys0tiKl0gLxYQHUjUCvrRSX8fYaPBmAKg==",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@stripe/stripe-js": "^1.44.1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-1.54.1.tgz",
|
||||
"integrity": "sha512-smEXPu1GKMcAj9g2luT16+oXfg2jAwyc68t2Dm5wdtYl3p8PqQaZEiI8tQmboaQAjgF8pIGma6byz1T1vgmpbA=="
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
|
||||
@@ -7596,6 +7616,19 @@
|
||||
"picomatch": "^2.2.2"
|
||||
}
|
||||
},
|
||||
"@stripe/react-stripe-js": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.1.1.tgz",
|
||||
"integrity": "sha512-aHm6cWOqmOyq3eKKlR42hgvrcZhzc9ijL+BeXJ5H3uaWUKKcooSsBys0tiKl0gLxYQHUjUCvrRSX8fYaPBmAKg==",
|
||||
"requires": {
|
||||
"prop-types": "^15.7.2"
|
||||
}
|
||||
},
|
||||
"@stripe/stripe-js": {
|
||||
"version": "1.54.1",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-1.54.1.tgz",
|
||||
"integrity": "sha512-smEXPu1GKMcAj9g2luT16+oXfg2jAwyc68t2Dm5wdtYl3p8PqQaZEiI8tQmboaQAjgF8pIGma6byz1T1vgmpbA=="
|
||||
},
|
||||
"@tsconfig/node10": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
"react-dom": "^18.0.0",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"wavesurfer.js": "^6.6.3"
|
||||
"wavesurfer.js": "^6.6.3",
|
||||
"@stripe/react-stripe-js": "^2.1.1",
|
||||
"@stripe/stripe-js": "^1.54.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
Currency,
|
||||
LimitField,
|
||||
LimitUnitOption,
|
||||
PasswordSettings,
|
||||
@@ -17,7 +18,13 @@ interface JambonzWindowObject {
|
||||
DISABLE_JAEGER_TRACING: string;
|
||||
DISABLE_CUSTOM_SPEECH: string;
|
||||
ENABLE_FORGOT_PASSWORD: string;
|
||||
ENABLE_HOSTED_SYSTEM: string;
|
||||
DISABLE_CALL_RECORDING: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
BASE_URL: string;
|
||||
DEFAULT_SERVICE_PROVIDER_SID: string;
|
||||
STRIPE_PUBLISHABLE_KEY: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -41,7 +48,12 @@ export const DISABLE_CUSTOM_SPEECH: boolean =
|
||||
/** Enable Forgot Password */
|
||||
export const ENABLE_FORGOT_PASSWORD: boolean =
|
||||
window.JAMBONZ?.ENABLE_FORGOT_PASSWORD === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_ENABLE_FORGOT_PASSWORD || "false");
|
||||
JSON.parse(import.meta.env.VITE_APP_ENABLE_FORGOT_PASSWORD || "false");
|
||||
|
||||
/** Enable Cloud version */
|
||||
export const ENABLE_HOSTED_SYSTEM: boolean =
|
||||
window.JAMBONZ?.ENABLE_HOSTED_SYSTEM === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_APP_ENABLE_HOSTED_SYSTEM || "false");
|
||||
/** Disable Lcr */
|
||||
export const DISABLE_LCR: boolean =
|
||||
window.JAMBONZ?.DISABLE_LCR === "true" ||
|
||||
@@ -57,6 +69,23 @@ export const DISABLE_CALL_RECORDING: boolean =
|
||||
window.JAMBONZ?.DISABLE_CALL_RECORDING === "true" ||
|
||||
JSON.parse(import.meta.env.VITE_APP_DISABLE_CALL_RECORDING || "false");
|
||||
|
||||
export const DEFAULT_SERVICE_PROVIDER_SID: string =
|
||||
window.JAMBONZ?.DEFAULT_SERVICE_PROVIDER_SID ||
|
||||
import.meta.env.VITE_APP_DEFAULT_SERVICE_PROVIDER_SID;
|
||||
|
||||
export const GITHUB_CLIENT_ID: string =
|
||||
window.JAMBONZ?.GITHUB_CLIENT_ID || import.meta.env.VITE_APP_GITHUB_CLIENT_ID;
|
||||
|
||||
export const BASE_URL: string =
|
||||
window.JAMBONZ?.BASE_URL || import.meta.env.VITE_APP_BASE_URL;
|
||||
|
||||
export const GOOGLE_CLIENT_ID: string =
|
||||
window.JAMBONZ?.GOOGLE_CLIENT_ID || import.meta.env.VITE_APP_GOOGLE_CLIENT_ID;
|
||||
|
||||
export const STRIPE_PUBLISHABLE_KEY: string =
|
||||
window.JAMBONZ?.STRIPE_PUBLISHABLE_KEY ||
|
||||
import.meta.env.VITE_APP_STRIPE_PUBLISHABLE_KEY;
|
||||
|
||||
/** TCP Max Port */
|
||||
export const TCP_MAX_PORT = 65535;
|
||||
|
||||
@@ -249,6 +278,16 @@ export const DEFAULT_PSWD_SETTINGS: PasswordSettings = {
|
||||
require_special_character: 0,
|
||||
};
|
||||
|
||||
export const PlanType = {
|
||||
PAID: "paid",
|
||||
TRIAL: "trial",
|
||||
FREE: "free",
|
||||
};
|
||||
|
||||
export const CurrencySymbol: Currency = {
|
||||
usd: "$",
|
||||
};
|
||||
|
||||
/** User scope values values */
|
||||
export const USER_ADMIN = "admin";
|
||||
export const USER_SP = "service_provider";
|
||||
@@ -263,6 +302,9 @@ export const CRED_NOT_TESTED = "not tested";
|
||||
export const CARRIER_REG_OK = "ok";
|
||||
export const CARRIER_REG_FAIL = "fail";
|
||||
|
||||
export const PRIVACY_POLICY = "https://jambonz.org/privacy";
|
||||
export const TERMS_OF_SERVICE = "https://jambonz.org/terms";
|
||||
|
||||
/** API base paths */
|
||||
export const API_LOGIN = `${API_BASE_URL}/login`;
|
||||
export const API_LOGOUT = `${API_BASE_URL}/logout`;
|
||||
@@ -285,3 +327,10 @@ export const API_LCR_ROUTES = `${API_BASE_URL}/LcrRoutes`;
|
||||
export const API_LCR_CARRIER_SET_ENTRIES = `${API_BASE_URL}/LcrCarrierSetEntries`;
|
||||
export const API_TTS_CACHE = `${API_BASE_URL}/TtsCache`;
|
||||
export const API_CLIENTS = `${API_BASE_URL}/Clients`;
|
||||
export const API_REGISTER = `${API_BASE_URL}/register`;
|
||||
export const API_ACTIVATION_CODE = `${API_BASE_URL}/ActivationCode`;
|
||||
export const API_AVAILABILITY = `${API_BASE_URL}/Availability`;
|
||||
export const API_PRICE = `${API_BASE_URL}/Prices`;
|
||||
export const API_SUBSCRIPTIONS = `${API_BASE_URL}/Subscriptions`;
|
||||
export const API_CHANGE_PASSWORD = `${API_BASE_URL}/change-password`;
|
||||
export const API_SIGNIN = `${API_BASE_URL}/signin`;
|
||||
|
||||
@@ -26,6 +26,13 @@ import {
|
||||
API_LCRS,
|
||||
API_TTS_CACHE,
|
||||
API_CLIENTS,
|
||||
API_REGISTER,
|
||||
API_ACTIVATION_CODE,
|
||||
API_AVAILABILITY,
|
||||
API_PRICE,
|
||||
API_SUBSCRIPTIONS,
|
||||
API_CHANGE_PASSWORD,
|
||||
API_SIGNIN,
|
||||
} from "./constants";
|
||||
import { ROUTE_LOGIN } from "src/router/routes";
|
||||
import {
|
||||
@@ -74,8 +81,17 @@ import type {
|
||||
BucketCredential,
|
||||
BucketCredentialTestResult,
|
||||
Client,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
ActivationCode,
|
||||
CurrentUserData,
|
||||
PriceInfo,
|
||||
Subscription,
|
||||
DeleteAccount,
|
||||
ChangePassword,
|
||||
SignIn,
|
||||
} from "./types";
|
||||
import { StatusCodes } from "./types";
|
||||
import { Availability, StatusCodes } from "./types";
|
||||
import { JaegerRoot } from "./jaeger-types";
|
||||
|
||||
/** Wrap all requests to normalize response handling */
|
||||
@@ -163,7 +179,7 @@ const getAuthHeaders = () => {
|
||||
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -239,6 +255,17 @@ export const deleteFetch = <Type>(url: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteFetchWithPayload = <Type, Payload>(
|
||||
url: string,
|
||||
payload: Payload
|
||||
) => {
|
||||
return fetchTransport<Type>(url, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
};
|
||||
|
||||
/** All APIs need a wrapper utility that uses the FetchTransport */
|
||||
|
||||
export const postLogin = (payload: UserLoginPayload) => {
|
||||
@@ -413,6 +440,37 @@ export const postLcrCarrierSetEntry = (
|
||||
export const postClient = (payload: Partial<Client>) => {
|
||||
return postFetch<SidResponse, Partial<Client>>(API_CLIENTS, payload);
|
||||
};
|
||||
|
||||
export const postRegister = (payload: Partial<RegisterRequest>) => {
|
||||
return postFetch<RegisterResponse, Partial<RegisterRequest>>(
|
||||
API_REGISTER,
|
||||
payload
|
||||
);
|
||||
};
|
||||
|
||||
export const postSipRealms = (accountSid: string, domain: string) => {
|
||||
return postFetch<EmptyResponse>(
|
||||
`${API_ACCOUNTS}/${accountSid}/SipRealms/${domain}`
|
||||
);
|
||||
};
|
||||
|
||||
export const postSubscriptions = (payload: Partial<Subscription>) => {
|
||||
return postFetch<Subscription, Partial<Subscription>>(
|
||||
API_SUBSCRIPTIONS,
|
||||
payload
|
||||
);
|
||||
};
|
||||
|
||||
export const postChangepassword = (payload: Partial<ChangePassword>) => {
|
||||
return postFetch<EmptyResponse, Partial<ChangePassword>>(
|
||||
API_CHANGE_PASSWORD,
|
||||
payload
|
||||
);
|
||||
};
|
||||
|
||||
export const postSignIn = (payload: Partial<SignIn>) => {
|
||||
return postFetch<SignIn, Partial<SignIn>>(API_SIGNIN, payload);
|
||||
};
|
||||
/** Named wrappers for `putFetch` */
|
||||
|
||||
export const putUser = (sid: string, payload: Partial<UserUpdatePayload>) => {
|
||||
@@ -532,6 +590,16 @@ export const putClient = (sid: string, payload: Partial<Client>) => {
|
||||
payload
|
||||
);
|
||||
};
|
||||
|
||||
export const putActivationCode = (
|
||||
code: string,
|
||||
payload: Partial<ActivationCode>
|
||||
) => {
|
||||
return putFetch<EmptyResponse, Partial<ActivationCode>>(
|
||||
`${API_ACTIVATION_CODE}/${code}`,
|
||||
payload
|
||||
);
|
||||
};
|
||||
/** Named wrappers for `deleteFetch` */
|
||||
|
||||
export const deleteUser = (sid: string) => {
|
||||
@@ -546,8 +614,11 @@ export const deleteApiKey = (sid: string) => {
|
||||
return deleteFetch<EmptyResponse>(`${API_API_KEYS}/${sid}`);
|
||||
};
|
||||
|
||||
export const deleteAccount = (sid: string) => {
|
||||
return deleteFetch<EmptyResponse>(`${API_ACCOUNTS}/${sid}`);
|
||||
export const deleteAccount = (sid: string, payload: Partial<DeleteAccount>) => {
|
||||
return deleteFetchWithPayload<EmptyResponse, Partial<DeleteAccount>>(
|
||||
`${API_ACCOUNTS}/${sid}`,
|
||||
payload
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteApplication = (sid: string) => {
|
||||
@@ -660,8 +731,18 @@ export const getClient = (sid: string) => {
|
||||
return getFetch<Client[]>(`${API_CLIENTS}/${sid}`);
|
||||
};
|
||||
|
||||
export const getAvailability = (domain: string) => {
|
||||
return getFetch<Availability>(
|
||||
`${API_AVAILABILITY}?type=subdomain&value=${domain}`
|
||||
);
|
||||
};
|
||||
|
||||
/** Wrappers for APIs that can have a mock dev server response */
|
||||
|
||||
export const getMe = () => {
|
||||
return getFetch<CurrentUserData>(`${API_USERS}/me`);
|
||||
};
|
||||
|
||||
export const getRecentCalls = (sid: string, query: Partial<CallQuery>) => {
|
||||
const qryStr = getQuery<Partial<CallQuery>>(query);
|
||||
|
||||
@@ -729,6 +810,10 @@ export const getAlerts = (sid: string, query: Partial<PageQuery>) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const getPrice = () => {
|
||||
return getFetch<PriceInfo[]>(API_PRICE);
|
||||
};
|
||||
|
||||
/** Hooks for components to fetch data with refetch method */
|
||||
|
||||
/** :GET /{apiPath} -- this is generic for any fetch of data collections */
|
||||
|
||||
153
src/api/types.ts
153
src/api/types.ts
@@ -148,6 +148,7 @@ export interface User {
|
||||
service_provider_name?: string | null;
|
||||
initial_password?: string;
|
||||
permissions?: UserPermissions[];
|
||||
provider?: null | string;
|
||||
}
|
||||
|
||||
export interface UserLogin {
|
||||
@@ -186,6 +187,8 @@ export interface UserJWT {
|
||||
|
||||
export interface CurrentUserData {
|
||||
user: User;
|
||||
account?: Account;
|
||||
subscription?: null | Subscription;
|
||||
}
|
||||
|
||||
export interface ServiceProvider {
|
||||
@@ -245,6 +248,7 @@ export interface Smpp {
|
||||
export interface Account {
|
||||
name: string;
|
||||
sip_realm: null | string;
|
||||
root_domain?: null | string;
|
||||
account_sid: string;
|
||||
webhook_secret: string;
|
||||
siprec_hook_sid: null | string;
|
||||
@@ -255,6 +259,37 @@ export interface Account {
|
||||
record_all_calls: number;
|
||||
record_format?: null | string;
|
||||
bucket_credential: null | BucketCredential;
|
||||
plan_type?: string;
|
||||
device_to_call_ratio?: number;
|
||||
trial_end_date?: null | string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
price_id?: null | string;
|
||||
product_sid?: null | string;
|
||||
name?: string;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
action?: null | string;
|
||||
payment_method_id?: null | string;
|
||||
account_subscription_sid?: null | string;
|
||||
stripe_customer_id?: null | string;
|
||||
products?: null | Product[];
|
||||
start_date?: string;
|
||||
status?: string;
|
||||
client_secret?: null | string;
|
||||
last4?: null | string;
|
||||
exp_month?: null | string;
|
||||
exp_year?: null | string;
|
||||
card_type?: null | string;
|
||||
reason?: null | string;
|
||||
dry_run?: boolean;
|
||||
currency?: null | string;
|
||||
prorated_cost?: number;
|
||||
monthly_cost?: number;
|
||||
next_invoice_date?: null | string;
|
||||
}
|
||||
|
||||
export interface AwsTag {
|
||||
@@ -504,3 +539,121 @@ export interface EmptyResponse {
|
||||
export interface TotalResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
service_provider_sid: string;
|
||||
provider: string;
|
||||
oauth2_code?: string;
|
||||
oauth2_state?: string;
|
||||
oauth2_client_id?: string;
|
||||
oauth2_redirect_uri?: string;
|
||||
locationBeforeAuth?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
email_activation_code?: string;
|
||||
inviteCode?: string;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
jwt: string;
|
||||
user_sid: string;
|
||||
account_sid: string;
|
||||
root_domain: string;
|
||||
}
|
||||
|
||||
export interface ActivationCode {
|
||||
user_sid: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Availability {
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
total: number;
|
||||
currency: null | string;
|
||||
next_payment_attempt: null | string;
|
||||
}
|
||||
|
||||
export type Currency = {
|
||||
[key: string]: null | string;
|
||||
};
|
||||
|
||||
export interface Recurring {
|
||||
aggregate_usage: null | string;
|
||||
interval: null | string;
|
||||
interval_count: number;
|
||||
trial_period_days: null | string;
|
||||
usage_type: string;
|
||||
}
|
||||
|
||||
export interface Price {
|
||||
billing_scheme: string;
|
||||
currency: string;
|
||||
recurring: Recurring;
|
||||
stripe_price_id: null | string;
|
||||
tiers_mode: null | string;
|
||||
type: null | string;
|
||||
unit_amount: number;
|
||||
unit_amount_decimal: null | string;
|
||||
}
|
||||
|
||||
export interface PriceInfo {
|
||||
category: null | string;
|
||||
description: null | string;
|
||||
name: null | string;
|
||||
prices: Price[];
|
||||
product_sid: null | string;
|
||||
stripe_product_id: null | string;
|
||||
unit_label: null | string;
|
||||
}
|
||||
|
||||
export interface StripeCustomerId {
|
||||
stripe_customer_id: null | string;
|
||||
}
|
||||
|
||||
export interface Tier {
|
||||
up_to: number;
|
||||
flat_amount: number;
|
||||
unit_amount: number;
|
||||
}
|
||||
|
||||
export interface ServiceData {
|
||||
category: null | string;
|
||||
name: null | string;
|
||||
service: null | string;
|
||||
fees: number;
|
||||
feesLabel: null | string;
|
||||
cost: number;
|
||||
capacity: number;
|
||||
invalid: boolean;
|
||||
currency: null | string;
|
||||
min: number;
|
||||
max: number;
|
||||
dirty: boolean;
|
||||
visible: boolean;
|
||||
required: boolean;
|
||||
billing_scheme?: null | string;
|
||||
stripe_price_id?: null | string;
|
||||
unit_label?: null | string;
|
||||
product_sid?: null | string;
|
||||
stripe_product_id?: null | string;
|
||||
tiers?: Tier[];
|
||||
}
|
||||
|
||||
export interface DeleteAccount {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ChangePassword {
|
||||
old_password: null | string;
|
||||
new_password: null | string;
|
||||
}
|
||||
|
||||
export interface SignIn {
|
||||
link?: null | string;
|
||||
jwt?: null | string;
|
||||
account_sid?: null | string;
|
||||
}
|
||||
|
||||
39
src/components/editboard/index.tsx
Normal file
39
src/components/editboard/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Icons } from "src/components/icons";
|
||||
|
||||
type EditBoardProps = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
text: string;
|
||||
path: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export const EditBoard = ({
|
||||
text,
|
||||
id = "",
|
||||
name = "",
|
||||
path,
|
||||
title,
|
||||
}: EditBoardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const handleClick = () => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="clipboard inpbtn">
|
||||
<input id={id} name={name} type="text" readOnly value={text} />
|
||||
<button
|
||||
className="btnty"
|
||||
type="button"
|
||||
title={title ? title : "Edit"}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icons.Edit />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ type CheckzoneProps = {
|
||||
id?: string;
|
||||
name: string;
|
||||
label: string;
|
||||
labelNode?: React.ReactNode;
|
||||
hidden?: boolean;
|
||||
children: React.ReactNode;
|
||||
initialCheck: boolean;
|
||||
@@ -22,6 +23,7 @@ export const Checkzone = forwardRef<CheckzoneRef, CheckzoneProps>(
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
labelNode,
|
||||
hidden = false,
|
||||
children,
|
||||
initialCheck,
|
||||
@@ -47,21 +49,24 @@ export const Checkzone = forwardRef<CheckzoneRef, CheckzoneProps>(
|
||||
return (
|
||||
<div className={classesTop}>
|
||||
<label>
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
id={id || name}
|
||||
onChange={(e) => {
|
||||
setChecked(e.target.checked);
|
||||
<div className="label-container">
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
id={id || name}
|
||||
onChange={(e) => {
|
||||
setChecked(e.target.checked);
|
||||
|
||||
if (handleChecked) {
|
||||
handleChecked(e);
|
||||
}
|
||||
}}
|
||||
checked={checked}
|
||||
/>
|
||||
<div>{label}</div>
|
||||
if (handleChecked) {
|
||||
handleChecked(e);
|
||||
}
|
||||
}}
|
||||
checked={checked}
|
||||
/>
|
||||
{label && <div>{label}</div>}
|
||||
{labelNode && labelNode}
|
||||
</div>
|
||||
</label>
|
||||
{checked && <div className={classesIn}>{children}</div>}
|
||||
</div>
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
width: 100%;
|
||||
max-width: vars.$widthinput;
|
||||
|
||||
> label {
|
||||
.label-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
> label {
|
||||
input {
|
||||
margin-top: ui-vars.$px01;
|
||||
margin-right: ui-vars.$px02;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +38,10 @@
|
||||
margin-top: ui-vars.$px01;
|
||||
}
|
||||
|
||||
> a {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.active {
|
||||
cursor: auto;
|
||||
opacity: 1;
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
ChevronsRight,
|
||||
Download,
|
||||
Smartphone,
|
||||
Youtube,
|
||||
Mail,
|
||||
} from "react-feather";
|
||||
|
||||
import type { Icon } from "react-feather";
|
||||
@@ -106,4 +108,6 @@ export const Icons: IconMap = {
|
||||
ChevronsRight,
|
||||
Download,
|
||||
Smartphone,
|
||||
Youtube,
|
||||
Mail,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom";
|
||||
import { Button, ButtonGroup } from "@jambonz/ui-kit";
|
||||
|
||||
import "./styles.scss";
|
||||
import { Spinner } from "../spinner";
|
||||
|
||||
type ModalProps = {
|
||||
disabled?: boolean;
|
||||
@@ -69,6 +70,7 @@ export const ModalForm = ({
|
||||
}}
|
||||
>
|
||||
<div className="modal__stuff">{children}</div>
|
||||
|
||||
<ButtonGroup right>
|
||||
<Button
|
||||
small
|
||||
@@ -114,3 +116,37 @@ export const ModalClose = ({ children, handleClose }: CloseProps) => {
|
||||
portal
|
||||
);
|
||||
};
|
||||
|
||||
type LoaderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ModalLoader = ({ children }: LoaderProps) => {
|
||||
return ReactDOM.createPortal(
|
||||
<div className="modal" role="presentation">
|
||||
<div
|
||||
className="modal__box"
|
||||
role="presentation"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="modal__stuff"
|
||||
role="presentation"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
portal
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
export const TOAST_TIME = 3000;
|
||||
export const TOAST_TIME = 5000;
|
||||
export const SESS_FLASH_MSG = "SESS_FLASH_MSG";
|
||||
export const SESS_USER_SID = "SESS_USER_SID";
|
||||
export const SESS_OLD_PASSWORD = "SESS_OLD_PASSWORD";
|
||||
|
||||
@@ -18,7 +18,10 @@ import { Scope, UserData } from "src/store/types";
|
||||
import type { Icon } from "react-feather";
|
||||
import type { ACL } from "src/store/types";
|
||||
import { Lcr } from "src/api/types";
|
||||
import { DISABLE_LCR } from "src/api/constants";
|
||||
import {
|
||||
DISABLE_LCR,
|
||||
ENABLE_HOSTED_SYSTEM as ENABLE_HOSTED_SYSTEM,
|
||||
} from "src/api/constants";
|
||||
|
||||
export interface NaviItem {
|
||||
label: string;
|
||||
@@ -30,11 +33,17 @@ export interface NaviItem {
|
||||
}
|
||||
|
||||
export const naviTop: NaviItem[] = [
|
||||
{
|
||||
label: "Users",
|
||||
icon: Icons.UserCheck,
|
||||
route: () => ROUTE_INTERNAL_USERS,
|
||||
},
|
||||
// User is not allowed in hosted app
|
||||
...(!ENABLE_HOSTED_SYSTEM
|
||||
? [
|
||||
{
|
||||
label: "Users",
|
||||
icon: Icons.UserCheck,
|
||||
route: () => ROUTE_INTERNAL_USERS,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
label: "Settings",
|
||||
icon: Icons.Settings,
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import React, { useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Icons } from "src/components";
|
||||
import { ROUTE_INTERNAL_USERS } from "src/router/routes";
|
||||
import {
|
||||
ROUTE_INTERNAL_USERS,
|
||||
ROUTE_REGISTER_SUB_DOMAIN,
|
||||
} from "src/router/routes";
|
||||
import { useApiData } from "src/api";
|
||||
import { useSelectState } from "src/store";
|
||||
|
||||
import type { CurrentUserData } from "src/api/types";
|
||||
|
||||
import "./styles.scss";
|
||||
import { ENABLE_HOSTED_SYSTEM } from "src/api/constants";
|
||||
import { setRootDomain } from "src/store/localStore";
|
||||
|
||||
export const UserMe = () => {
|
||||
const user = useSelectState("user");
|
||||
const [userData] = useApiData<CurrentUserData>("Users/me");
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// If hosted platform is enabled, the account should have sip realm
|
||||
if (ENABLE_HOSTED_SYSTEM && userData && !userData.account?.sip_realm) {
|
||||
setRootDomain(userData?.account?.root_domain || "");
|
||||
navigate(ROUTE_REGISTER_SUB_DOMAIN);
|
||||
}
|
||||
}, [userData]);
|
||||
|
||||
return (
|
||||
<div className="user">
|
||||
|
||||
85
src/containers/internal/views/accounts/edit-sip-realm.tsx
Normal file
85
src/containers/internal/views/accounts/edit-sip-realm.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Button, ButtonGroup, H1, MS } from "@jambonz/ui-kit";
|
||||
import React, { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { getAvailability, postSipRealms, useApiData } from "src/api";
|
||||
import { CurrentUserData } from "src/api/types";
|
||||
import { Section } from "src/components";
|
||||
import { Message } from "src/components/forms";
|
||||
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
|
||||
|
||||
export const EditSipRealm = () => {
|
||||
const [name, setName] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const [userData] = useApiData<CurrentUserData>("Users/me");
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const rootDomain = userData?.account?.root_domain;
|
||||
const account_sid = userData?.account?.account_sid;
|
||||
|
||||
getAvailability(`${name}.${rootDomain}`)
|
||||
.then(({ json }) => {
|
||||
if (!json.available) {
|
||||
setErrorMessage("That subdomain is not available.");
|
||||
return;
|
||||
}
|
||||
postSipRealms(account_sid || "", `${name}.${rootDomain}`)
|
||||
.then(() => {
|
||||
navigate(`${ROUTE_INTERNAL_ACCOUNTS}/${account_sid}/edit`);
|
||||
})
|
||||
.catch((error) => {
|
||||
setErrorMessage(error.msg);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setErrorMessage(error.msg);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Edit Sip Realm</H1>
|
||||
<Section slim>
|
||||
<form className="form form--internal" onSubmit={handleSubmit}>
|
||||
<fieldset>
|
||||
<MS>
|
||||
This is the domain name where your carrier will send calls, and
|
||||
where you can register devices to.
|
||||
</MS>
|
||||
{errorMessage && <Message message={errorMessage} />}
|
||||
<br />
|
||||
<input
|
||||
id="name"
|
||||
required
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="fqdn">
|
||||
FQDN: {name}.{userData?.account?.root_domain}
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<ButtonGroup left>
|
||||
<Button
|
||||
small
|
||||
subStyle="grey"
|
||||
as={Link}
|
||||
to={`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" small>
|
||||
Change Sip Realm
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditSipRealm;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { P, Button, ButtonGroup, MS, Icon } from "@jambonz/ui-kit";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { P, Button, ButtonGroup, MS, Icon, H1 } from "@jambonz/ui-kit";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { toastError, toastSuccess, useSelectState } from "src/store";
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
deleteAccountLimit,
|
||||
deleteAccountTtsCache,
|
||||
postAccountBucketCredentialTest,
|
||||
deleteAccount,
|
||||
} from "src/api";
|
||||
import { ClipBoard, Icons, Modal, Section, Tooltip } from "src/components";
|
||||
import {
|
||||
@@ -30,14 +31,17 @@ import {
|
||||
BUCKET_VENDOR_GOOGLE,
|
||||
BUCKET_VENDOR_OPTIONS,
|
||||
CRED_OK,
|
||||
CurrencySymbol,
|
||||
DEFAULT_WEBHOOK,
|
||||
DISABLE_CALL_RECORDING,
|
||||
ENABLE_HOSTED_SYSTEM,
|
||||
PlanType,
|
||||
USER_ACCOUNT,
|
||||
WEBHOOK_METHODS,
|
||||
} from "src/api/constants";
|
||||
import { MSG_REQUIRED_FIELDS, MSG_WEBHOOK_FIELDS } from "src/constants";
|
||||
|
||||
import type {
|
||||
import {
|
||||
WebHook,
|
||||
Account,
|
||||
Application,
|
||||
@@ -47,11 +51,19 @@ import type {
|
||||
TtsCache,
|
||||
BucketCredential,
|
||||
AwsTag,
|
||||
Invoice,
|
||||
CurrentUserData,
|
||||
Carrier,
|
||||
SpeechCredential,
|
||||
} from "src/api/types";
|
||||
import { hasLength, hasValue } from "src/utils";
|
||||
import { useRegionVendors } from "src/vendor";
|
||||
import { GoogleServiceKey } from "src/vendor/types";
|
||||
import { getObscuredGoogleServiceKey } from "../speech-services/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { EditBoard } from "src/components/editboard";
|
||||
import { ModalLoader } from "src/components/modal";
|
||||
import { useAuth } from "src/router/auth";
|
||||
|
||||
type AccountFormProps = {
|
||||
apps?: Application[];
|
||||
@@ -66,14 +78,22 @@ export const AccountForm = ({
|
||||
account,
|
||||
ttsCache,
|
||||
}: AccountFormProps) => {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const user = useSelectState("user");
|
||||
const currentServiceProvider = useSelectState("currentServiceProvider");
|
||||
const [accounts] = useApiData<Account[]>("Accounts");
|
||||
const [invoice] = useApiData<Invoice>("Invoices");
|
||||
const [userData] = useApiData<CurrentUserData>("Users/me");
|
||||
const [userCarriers] = useApiData<Carrier[]>(`VoipCarriers`);
|
||||
const [userSpeechs] = useApiData<SpeechCredential[]>(
|
||||
`/Accounts/${params.account_sid}/SpeechCredentials`
|
||||
);
|
||||
const [name, setName] = useState("");
|
||||
const [realm, setRealm] = useState("");
|
||||
const [appId, setAppId] = useState("");
|
||||
const [recId, setRecId] = useState("");
|
||||
const { signout } = useAuth();
|
||||
const [regHook, setRegHook] = useState<WebHook>(DEFAULT_WEBHOOK);
|
||||
const [queueHook, setQueueHook] = useState<WebHook>(DEFAULT_WEBHOOK);
|
||||
const [modal, setModal] = useState(false);
|
||||
@@ -100,6 +120,15 @@ export const AccountForm = ({
|
||||
const [tmpBucketGoogleServiceKey, setTmpBucketGoogleServiceKey] =
|
||||
useState<GoogleServiceKey | null>(null);
|
||||
const regions = useRegionVendors();
|
||||
const [subscriptionDescription, setSubscriptionDescription] = useState("");
|
||||
const [isDeleteAccount, setIsDeleteAccount] = useState(false);
|
||||
const [requiresPassword, setRequiresPassword] = useState(true);
|
||||
const [deleteAccountPasswd, setDeleteAccountPasswd] = useState("");
|
||||
const [deleteMessage, setDeleteMessage] = useState("");
|
||||
const [isDisableDeleteAccountButton, setIsDisableDeleteAccountButton] =
|
||||
useState(false);
|
||||
const deleteMessageRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
|
||||
|
||||
/** This lets us map and render the same UI for each... */
|
||||
const webhooks = [
|
||||
@@ -135,6 +164,49 @@ export const AccountForm = ({
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isDeleteAccount &&
|
||||
deleteMessageRef.current &&
|
||||
deleteMessageRef.current !== document.activeElement
|
||||
) {
|
||||
deleteMessageRef.current.focus();
|
||||
}
|
||||
}, [isDeleteAccount]);
|
||||
|
||||
const handleDeleteAccount = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (deleteMessage !== "delete my account") {
|
||||
toastError(
|
||||
"You must type the delete message correctly in order to delete your account."
|
||||
);
|
||||
if (
|
||||
deleteMessageRef.current &&
|
||||
deleteMessageRef.current !== document.activeElement
|
||||
) {
|
||||
deleteMessageRef.current.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
setIsDisableDeleteAccountButton(true);
|
||||
setIsShowModalLoader(true);
|
||||
|
||||
deleteAccount(userData?.account?.account_sid || "", {
|
||||
password: deleteAccountPasswd,
|
||||
})
|
||||
.then(() => {
|
||||
signout();
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDisableDeleteAccountButton(false);
|
||||
setIsShowModalLoader(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -293,7 +365,7 @@ export const AccountForm = ({
|
||||
if (account && account.data) {
|
||||
putAccount(account.data.account_sid, {
|
||||
name,
|
||||
sip_realm: realm || null,
|
||||
...(!ENABLE_HOSTED_SYSTEM && { sip_realm: realm || null }),
|
||||
webhook_secret: account.data.webhook_secret,
|
||||
siprec_hook_sid: recId || null,
|
||||
queue_event_hook: queueHook || account.data.queue_event_hook,
|
||||
@@ -448,6 +520,69 @@ export const AccountForm = ({
|
||||
}
|
||||
}, [account]);
|
||||
|
||||
if (ENABLE_HOSTED_SYSTEM) {
|
||||
useEffect(() => {
|
||||
if (userData && userData.user) {
|
||||
setRequiresPassword(userData.user.provider === "local");
|
||||
}
|
||||
if (userData && userData.account) {
|
||||
const pType = userData.account.plan_type;
|
||||
const { products } = userData.subscription || {};
|
||||
const registeredDeviceRecord = products
|
||||
? products.find((item) => item.name === "registered device") || {
|
||||
quantity: 0,
|
||||
}
|
||||
: { quantity: 0 };
|
||||
const callSessionRecord = products
|
||||
? products.find(
|
||||
(item) => item.name === "concurrent call session"
|
||||
) || { quantity: 0 }
|
||||
: { quantity: 0 };
|
||||
const quantity =
|
||||
(userData.account.device_to_call_ratio || 0) *
|
||||
(callSessionRecord.quantity || 0) +
|
||||
(registeredDeviceRecord.quantity || 0);
|
||||
const { trial_end_date } = userData.account || {};
|
||||
switch (pType) {
|
||||
case PlanType.TRIAL:
|
||||
setSubscriptionDescription(
|
||||
`You are currently on the Free plan (trial period). You are limited to ${
|
||||
callSessionRecord.quantity
|
||||
} simultaneous calls and ${quantity} registered devices.${
|
||||
trial_end_date
|
||||
? ` Your free trial will end on ${dayjs(
|
||||
trial_end_date
|
||||
).format("MMM DD, YYYY")}.`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
break;
|
||||
case PlanType.PAID:
|
||||
if (invoice) {
|
||||
setSubscriptionDescription(
|
||||
`Your paid subscription includes capacity for ${
|
||||
callSessionRecord.quantity
|
||||
} simultaneous calls, and ${quantity} registered devices. You are billed ${
|
||||
CurrencySymbol[invoice.currency || "usd"]
|
||||
}${(invoice.total || 0) / 100} on ${dayjs
|
||||
.unix(Number(invoice.next_payment_attempt))
|
||||
.format("MMM DD, YYYY")}.`
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
case PlanType.FREE:
|
||||
setSubscriptionDescription(
|
||||
`You are currently on the Free plan (trial period expired). You are limited to ${callSessionRecord.quantity} simultaneous calls and ${quantity} registered devices`
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Make sure Account page is alway scroll to top to see subscription
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}, [userData, invoice]);
|
||||
}
|
||||
|
||||
const updateBucketTags = (
|
||||
index: number,
|
||||
key: string,
|
||||
@@ -470,6 +605,164 @@ export const AccountForm = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{ENABLE_HOSTED_SYSTEM && (
|
||||
<>
|
||||
<Section>
|
||||
<H1 className="h5">Your Subscription</H1>
|
||||
<P>{subscriptionDescription}</P>
|
||||
<br />
|
||||
|
||||
<div className="mast">
|
||||
<ButtonGroup left>
|
||||
<Button
|
||||
type="button"
|
||||
mainStyle="hollow"
|
||||
subStyle="grey"
|
||||
small
|
||||
onClick={() => setIsDeleteAccount(true)}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup right>
|
||||
{userData?.account?.plan_type === PlanType.PAID ? (
|
||||
<>
|
||||
<Button
|
||||
small
|
||||
as={Link}
|
||||
to={`${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/manage-payment`}
|
||||
>
|
||||
Manage Payment Info
|
||||
</Button>
|
||||
<Button
|
||||
small
|
||||
as={Link}
|
||||
to={`${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/modify-subscription`}
|
||||
>
|
||||
Modify My Subscription
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
as={Link}
|
||||
to={`${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/subscription`}
|
||||
>
|
||||
Upgrade to a Paid Subscription
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</Section>
|
||||
{isDeleteAccount && (
|
||||
<Section slim>
|
||||
<form
|
||||
className="form form--internal"
|
||||
onSubmit={handleDeleteAccount}
|
||||
>
|
||||
<fieldset>
|
||||
<H1 className="h4">Delete Account</H1>
|
||||
<P>
|
||||
<span>
|
||||
<strong>Warning!</strong>
|
||||
</span>{" "}
|
||||
This will permantly delete all of your data from our
|
||||
database. You will not be able to restore your account. You
|
||||
must {requiresPassword && "provide your password and"} type
|
||||
“delete my account” into the Delete Message field.
|
||||
</P>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
{requiresPassword && (
|
||||
<>
|
||||
<label htmlFor="password">
|
||||
Password<span>*</span>
|
||||
</label>
|
||||
<Passwd
|
||||
id="delete_account_password"
|
||||
name="delete_account_password"
|
||||
value={deleteAccountPasswd}
|
||||
placeholder="Password"
|
||||
required
|
||||
onChange={(e) => {
|
||||
setDeleteAccountPasswd(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<label htmlFor="deleteMessage">
|
||||
Delete Message<span>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="deleteMessage"
|
||||
required
|
||||
type="text"
|
||||
name="deleteMessage"
|
||||
placeholder="Delete Message"
|
||||
value={deleteMessage}
|
||||
ref={deleteMessageRef}
|
||||
onChange={(e) => setDeleteMessage(e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<ButtonGroup right>
|
||||
<Button
|
||||
subStyle="grey"
|
||||
type="button"
|
||||
onClick={() => setIsDeleteAccount(false)}
|
||||
small
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisableDeleteAccountButton}
|
||||
small
|
||||
>
|
||||
PERMANENTLY DELETE MY ACCOUNT
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Section>
|
||||
)}
|
||||
{(!userCarriers ||
|
||||
userCarriers.length === 0 ||
|
||||
!userSpeechs ||
|
||||
userSpeechs.length === 0) && (
|
||||
<Section>
|
||||
<H1 className="h5">Finish Account Setup</H1>
|
||||
<H1 className="h6">To do</H1>
|
||||
{(!userCarriers || userCarriers.length === 0) && (
|
||||
<>
|
||||
<br />
|
||||
<div>
|
||||
<span>
|
||||
<Icons.Edit />
|
||||
Add a <Link to="/internal/carriers">carrier</Link> to
|
||||
route calls
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(!userSpeechs || userSpeechs.length === 0) && (
|
||||
<>
|
||||
<br />
|
||||
<div>
|
||||
<span>
|
||||
<Icons.Edit />
|
||||
Add <Link to="/internal/speech-services">
|
||||
speech
|
||||
</Link>{" "}
|
||||
credentials for text-to-speech and speech-to-text
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Section slim>
|
||||
<form className="form form--internal" onSubmit={handleSubmit}>
|
||||
<fieldset>
|
||||
@@ -499,22 +792,34 @@ export const AccountForm = ({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<LocalLimits
|
||||
data={limits && limits.data}
|
||||
limits={[localLimits, setLocalLimits]}
|
||||
/>
|
||||
</fieldset>
|
||||
{!ENABLE_HOSTED_SYSTEM && (
|
||||
<fieldset>
|
||||
<LocalLimits
|
||||
data={limits && limits.data}
|
||||
limits={[localLimits, setLocalLimits]}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
<fieldset>
|
||||
<label htmlFor="sip_realm">SIP realm</label>
|
||||
<input
|
||||
id="sip_realm"
|
||||
type="text"
|
||||
name="sip_realm"
|
||||
placeholder="The domain name that SIP devices will register with"
|
||||
value={realm}
|
||||
onChange={(e) => setRealm(e.target.value)}
|
||||
/>
|
||||
{ENABLE_HOSTED_SYSTEM ? (
|
||||
<EditBoard
|
||||
id="sip_realm"
|
||||
name="sip_realm"
|
||||
text={realm}
|
||||
title="Change SIP Realm"
|
||||
path={`/internal/accounts/${user?.account_sid}/sip-realm/edit`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id="sip_realm"
|
||||
type="text"
|
||||
name="sip_realm"
|
||||
placeholder="The domain name that SIP devices will register with"
|
||||
value={realm}
|
||||
onChange={(e) => setRealm(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
{account && account.data && (
|
||||
<fieldset>
|
||||
@@ -909,14 +1214,17 @@ export const AccountForm = ({
|
||||
)}
|
||||
<fieldset>
|
||||
<ButtonGroup left>
|
||||
<Button
|
||||
small
|
||||
subStyle="grey"
|
||||
as={Link}
|
||||
to={ROUTE_INTERNAL_ACCOUNTS}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{user?.scope != USER_ACCOUNT && (
|
||||
<Button
|
||||
small
|
||||
subStyle="grey"
|
||||
as={Link}
|
||||
to={ROUTE_INTERNAL_ACCOUNTS}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="submit" small>
|
||||
Save
|
||||
</Button>
|
||||
@@ -940,6 +1248,14 @@ export const AccountForm = ({
|
||||
<P>Are you sure you want to clean TTS cache for this account?</P>
|
||||
</Modal>
|
||||
)}
|
||||
{isShowModalLoader && (
|
||||
<ModalLoader>
|
||||
<P>
|
||||
Your requested changes are being processed. Please do not leave the
|
||||
page or hit the back button until complete.
|
||||
</P>
|
||||
</ModalLoader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ export const Accounts = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteAccount(account.account_sid)
|
||||
deleteAccount(account.account_sid, {})
|
||||
.then(() => {
|
||||
refetch();
|
||||
setAccount(null);
|
||||
|
||||
187
src/containers/internal/views/accounts/manage-payment-form.tsx
Normal file
187
src/containers/internal/views/accounts/manage-payment-form.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Button, ButtonGroup, H1, P } from "@jambonz/ui-kit";
|
||||
import {
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import React, { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { postSubscriptions, useApiData } from "src/api";
|
||||
import { CurrentUserData, Subscription } from "src/api/types";
|
||||
import { Section } from "src/components";
|
||||
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
|
||||
import { toastError, toastSuccess, useSelectState } from "src/store";
|
||||
import { PaymentMethod } from "@stripe/stripe-js";
|
||||
import { ModalLoader } from "src/components/modal";
|
||||
|
||||
export const ManagePaymentForm = () => {
|
||||
const user = useSelectState("user");
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [userData] = useApiData<CurrentUserData>("Users/me");
|
||||
const [isChangePayment, setIsChangePayment] = useState(false);
|
||||
const [isSavingNewCard, setIsSavingNewCard] = useState(false);
|
||||
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const createSubscription = async (paymentMethod: PaymentMethod) => {
|
||||
const body: Subscription = {
|
||||
action: "update-payment-method",
|
||||
payment_method_id: paymentMethod.id,
|
||||
};
|
||||
|
||||
postSubscriptions(body)
|
||||
.then(({ json }) => {
|
||||
if (json.status === "success") {
|
||||
toastSuccess("Payment completed successfully");
|
||||
navigate(
|
||||
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`
|
||||
);
|
||||
} else if (json.status === "action required") {
|
||||
if (stripe) {
|
||||
const location = window.location;
|
||||
stripe
|
||||
.confirmPayment({
|
||||
clientSecret: json.client_secret || "",
|
||||
confirmParams: {
|
||||
return_url: `${location.protocol}//${location.host}${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
|
||||
},
|
||||
})
|
||||
.then((error) => {
|
||||
if (error) {
|
||||
toastError(error.error.message || "");
|
||||
return;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSavingNewCard(false);
|
||||
setIsShowModalLoader(false);
|
||||
});
|
||||
}
|
||||
} else if (json.status === "card error") {
|
||||
setIsSavingNewCard(false);
|
||||
setIsShowModalLoader(false);
|
||||
toastError(json.reason || "Something went wrong, please try again.");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg || "Something went wrong, please try again.");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSavingNewCard(false);
|
||||
setIsShowModalLoader(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveNewCard = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
const card = elements.getElement(PaymentElement);
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
const { error: elementsError } = await elements.submit();
|
||||
if (elementsError) {
|
||||
toastError(elementsError.message || "");
|
||||
return;
|
||||
}
|
||||
const { error, paymentMethod } = await stripe.createPaymentMethod({
|
||||
element: card,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toastError(error.message || "Something went wrong, please try again.");
|
||||
return;
|
||||
}
|
||||
setIsSavingNewCard(true);
|
||||
setIsShowModalLoader(true);
|
||||
createSubscription(paymentMethod);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Manage Payment Information</H1>
|
||||
{userData?.subscription && (
|
||||
<Section>
|
||||
<H1 className="h3">Current Payment Information</H1>
|
||||
<div className="item__details">
|
||||
<div className="pre-grid-white">
|
||||
<div>Card Type:</div>
|
||||
<div>{userData.subscription.card_type}</div>
|
||||
<div>Card Number:</div>
|
||||
<div>
|
||||
{userData.subscription.last4
|
||||
? `**** **** **** ${userData.subscription.last4}`
|
||||
: ""}
|
||||
</div>
|
||||
<div>Expiration:</div>
|
||||
<div>
|
||||
{userData.subscription.exp_year
|
||||
? `${userData.subscription.exp_month}/${userData.subscription.exp_year}`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonGroup right>
|
||||
<Button
|
||||
type="button"
|
||||
subStyle="grey"
|
||||
as={Link}
|
||||
to={`${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/edit`}
|
||||
small
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => setIsChangePayment(true)} small>
|
||||
Change Payment Info
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Section>
|
||||
)}
|
||||
{isChangePayment && (
|
||||
<Section>
|
||||
<div className="grid--col4--users">
|
||||
<H1 className="h3">New Payment Information</H1>
|
||||
<div className="grid__row">
|
||||
<div></div>
|
||||
<div>
|
||||
<PaymentElement />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonGroup right>
|
||||
<Button
|
||||
type="button"
|
||||
subStyle="grey"
|
||||
onClick={() => setIsChangePayment(false)}
|
||||
small
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSaveNewCard}
|
||||
disabled={!stripe || isSavingNewCard}
|
||||
small
|
||||
>
|
||||
Save New Card
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Section>
|
||||
)}
|
||||
{isShowModalLoader && (
|
||||
<ModalLoader>
|
||||
<P>
|
||||
Your requested changes are being processed. Please do not leave the
|
||||
page or hit the back button until complete.
|
||||
</P>
|
||||
</ModalLoader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagePaymentForm;
|
||||
23
src/containers/internal/views/accounts/manage-payment.tsx
Normal file
23
src/containers/internal/views/accounts/manage-payment.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Elements } from "@stripe/react-stripe-js";
|
||||
import { stripePromise } from "./subscription";
|
||||
import ManagePaymentForm from "./manage-payment-form";
|
||||
import React from "react";
|
||||
|
||||
export const ManagePayment = () => {
|
||||
return (
|
||||
<>
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
mode: "setup",
|
||||
currency: "usd",
|
||||
paymentMethodCreation: "manual",
|
||||
}}
|
||||
>
|
||||
<ManagePaymentForm />
|
||||
</Elements>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagePayment;
|
||||
629
src/containers/internal/views/accounts/subscription-form.tsx
Normal file
629
src/containers/internal/views/accounts/subscription-form.tsx
Normal file
@@ -0,0 +1,629 @@
|
||||
import { Button, ButtonGroup, H1, P } from "@jambonz/ui-kit";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { postSubscriptions, useApiData } from "src/api";
|
||||
import { CurrencySymbol } from "src/api/constants";
|
||||
import {
|
||||
CurrentUserData,
|
||||
PriceInfo,
|
||||
ServiceData,
|
||||
StripeCustomerId,
|
||||
Subscription,
|
||||
} from "src/api/types";
|
||||
import { Modal, Section } from "src/components";
|
||||
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
|
||||
import { hasValue } from "src/utils";
|
||||
import {
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { PaymentMethod } from "@stripe/stripe-js";
|
||||
import { toastError, toastSuccess } from "src/store";
|
||||
import { ModalLoader } from "src/components/modal";
|
||||
|
||||
const SubscriptionForm = () => {
|
||||
const [userData] = useApiData<CurrentUserData>("Users/me");
|
||||
const [priceInfo] = useApiData<PriceInfo[]>("/Prices");
|
||||
const [userStripeInfo] = useApiData<StripeCustomerId>("/StripeCustomerId");
|
||||
const [total, setTotal] = useState(0);
|
||||
const [cardErrorCase, setCardErrorCase] = useState(false);
|
||||
const [isReviewChanges, setIsReviewChanges] = useState(false);
|
||||
const [isReturnToFreePlan, setIsReturnToFreePlan] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isModifySubscription = location.pathname.includes(
|
||||
"modify-subscription"
|
||||
);
|
||||
const [billingCharge, setBillingCharge] = useState<Subscription | null>(null);
|
||||
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
|
||||
const [isDisableSubmitButton, setIsDisableSubmitButton] =
|
||||
useState(isModifySubscription);
|
||||
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
const createSubscription = async (paymentMethod: PaymentMethod) => {
|
||||
let body: Subscription = {};
|
||||
|
||||
if (cardErrorCase) {
|
||||
body = {
|
||||
action: "update-payment-method",
|
||||
payment_method_id: paymentMethod.id,
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
action: "upgrade-to-paid",
|
||||
payment_method_id: paymentMethod.id,
|
||||
stripe_customer_id: userStripeInfo?.stripe_customer_id,
|
||||
products: serviceData.map((service) => ({
|
||||
price_id: service.stripe_price_id,
|
||||
product_sid: service.product_sid,
|
||||
quantity: service.capacity || 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
postSubscriptions(body)
|
||||
.then(({ json }) => {
|
||||
if (json.status === "success") {
|
||||
toastSuccess("Payment completed successfully");
|
||||
navigate(
|
||||
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`
|
||||
);
|
||||
} else if (json.status === "action required") {
|
||||
if (stripe) {
|
||||
const location = window.location;
|
||||
stripe
|
||||
.confirmPayment({
|
||||
clientSecret: json.client_secret || "",
|
||||
confirmParams: {
|
||||
return_url: `${location.protocol}//${location.host}${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
|
||||
},
|
||||
})
|
||||
.then((error) => {
|
||||
if (error) {
|
||||
toastError(error.error.message || "");
|
||||
return;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDisableSubmitButton(false);
|
||||
setIsShowModalLoader(false);
|
||||
});
|
||||
}
|
||||
} else if (json.status === "card error") {
|
||||
setIsDisableSubmitButton(false);
|
||||
setIsShowModalLoader(false);
|
||||
setCardErrorCase(true);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setIsDisableSubmitButton(false);
|
||||
setIsShowModalLoader(false);
|
||||
toastError(error.msg || "Something went wrong, please try again.");
|
||||
});
|
||||
};
|
||||
|
||||
const retrieveBillingChanges = async () => {
|
||||
const updatedProducts = serviceData.map((product) => ({
|
||||
price_id: product.stripe_price_id,
|
||||
product_sid: product.product_sid,
|
||||
quantity: product.capacity || 0,
|
||||
}));
|
||||
|
||||
postSubscriptions({
|
||||
action: "update-quantities",
|
||||
dry_run: true,
|
||||
products: updatedProducts,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setBillingCharge(json);
|
||||
setIsReviewChanges(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg || "Something went wrong, please try again.");
|
||||
setIsDisableSubmitButton(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDisableSubmitButton(true);
|
||||
if (isModifySubscription) {
|
||||
retrieveBillingChanges();
|
||||
return;
|
||||
}
|
||||
setIsShowModalLoader(true);
|
||||
const { error: elementsError } = await elements.submit();
|
||||
if (elementsError) {
|
||||
setIsDisableSubmitButton(false);
|
||||
setIsShowModalLoader(false);
|
||||
toastError(elementsError.message || "");
|
||||
return;
|
||||
}
|
||||
const card = elements.getElement(PaymentElement);
|
||||
if (!card) {
|
||||
setIsDisableSubmitButton(false);
|
||||
setIsShowModalLoader(false);
|
||||
return;
|
||||
}
|
||||
const { error, paymentMethod } = await stripe.createPaymentMethod({
|
||||
element: card,
|
||||
});
|
||||
if (error) {
|
||||
setIsDisableSubmitButton(false);
|
||||
setIsShowModalLoader(false);
|
||||
toastError(error.message || "");
|
||||
return;
|
||||
}
|
||||
|
||||
createSubscription(paymentMethod);
|
||||
};
|
||||
|
||||
const handleReturnToFreePlan = () => {
|
||||
setIsReturnToFreePlan(false);
|
||||
setIsShowModalLoader(true);
|
||||
const body: Subscription = {
|
||||
action: "downgrade-to-free",
|
||||
};
|
||||
|
||||
postSubscriptions(body)
|
||||
.then(() => {
|
||||
toastSuccess("Downgrade to free plan completed successfully");
|
||||
navigate(
|
||||
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
})
|
||||
.finally(() => setIsShowModalLoader(false));
|
||||
};
|
||||
|
||||
const handleReviewChangeSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsShowModalLoader(true);
|
||||
|
||||
const updatedProducts = serviceData.map((product) => ({
|
||||
price_id: product.stripe_price_id,
|
||||
product_sid: product.product_sid,
|
||||
quantity: product.capacity,
|
||||
}));
|
||||
|
||||
postSubscriptions({
|
||||
action: "update-quantities",
|
||||
products: updatedProducts,
|
||||
})
|
||||
.then(() => {
|
||||
toastSuccess(
|
||||
"Your subscription capacity has been successfully modified."
|
||||
);
|
||||
navigate(
|
||||
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toastError(
|
||||
`The additional capacity you that you requested could not be granted due to a failure processing payment.
|
||||
Please configure a valid credit card for your account and the upgrade will be automatically processed`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsShowModalLoader(false);
|
||||
setIsDisableSubmitButton(false);
|
||||
});
|
||||
};
|
||||
// subscription categories
|
||||
const [serviceData, setServiceData] = useState<ServiceData[]>([
|
||||
{
|
||||
category: "voice_call_session",
|
||||
name: "concurrent call session",
|
||||
service: "Maximum concurrent call sessions",
|
||||
fees: 0,
|
||||
feesLabel: "",
|
||||
cost: 0,
|
||||
capacity: 0,
|
||||
invalid: false,
|
||||
currency: "usd",
|
||||
min: 5,
|
||||
max: 1000,
|
||||
dirty: false,
|
||||
visible: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
category: "device",
|
||||
name: "registered device",
|
||||
service: "Additional device registrations",
|
||||
fees: 0,
|
||||
feesLabel: "",
|
||||
cost: 0,
|
||||
capacity: 0,
|
||||
invalid: false,
|
||||
currency: "usd",
|
||||
min: 0,
|
||||
max: 200,
|
||||
dirty: false,
|
||||
visible: false,
|
||||
required: false,
|
||||
},
|
||||
]);
|
||||
const [originalServiceData, setOriginalServiceData] = useState<ServiceData[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
const initFeesAndCost = (priceData: PriceInfo[]) => {
|
||||
serviceData.forEach((service) => {
|
||||
const record = priceData.find(
|
||||
(item) => item.category === service.category
|
||||
);
|
||||
|
||||
if (record) {
|
||||
const price = record.prices.find(
|
||||
(item) => item.currency === service.currency
|
||||
);
|
||||
|
||||
if (price) {
|
||||
let fees = 0;
|
||||
switch (price.billing_scheme) {
|
||||
case "per_unit":
|
||||
fees = (price.unit_amount * 1) / 100;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
service.billing_scheme = price.billing_scheme;
|
||||
service.stripe_price_id = price.stripe_price_id;
|
||||
service.unit_label = record.unit_label;
|
||||
service.product_sid = record.product_sid;
|
||||
service.stripe_product_id = record.stripe_product_id;
|
||||
service.fees = fees;
|
||||
service.feesLabel = `${
|
||||
CurrencySymbol[service.currency || "usd"]
|
||||
}${fees} per ${
|
||||
record.unit_label?.slice(0, 3) === "per"
|
||||
? record.unit_label.slice(3)
|
||||
: record.unit_label
|
||||
}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setServiceData([...serviceData]);
|
||||
};
|
||||
|
||||
const getServicePrice = (
|
||||
service: ServiceData,
|
||||
capacity: number
|
||||
): [number, string, number] => {
|
||||
let fees = 0;
|
||||
let feesLabel = "";
|
||||
let cost = 0;
|
||||
const capacityNum = capacity;
|
||||
if (service.billing_scheme === "per_unit") {
|
||||
fees = service.fees;
|
||||
cost = fees * capacityNum;
|
||||
} else if (service.billing_scheme === "tiered") {
|
||||
const filteredTiers = service.tiers
|
||||
? service.tiers.filter(
|
||||
(item) => !item.up_to || item.up_to >= capacityNum
|
||||
)
|
||||
: [];
|
||||
if (filteredTiers.length) {
|
||||
const tier = filteredTiers[0];
|
||||
if (typeof tier.flat_amount === "number") {
|
||||
fees = tier.flat_amount / 100;
|
||||
cost = fees;
|
||||
} else {
|
||||
fees = tier.unit_amount / 100;
|
||||
cost = fees * capacityNum;
|
||||
}
|
||||
}
|
||||
}
|
||||
feesLabel = `${CurrencySymbol[service.currency || "usd"]}${fees} per ${
|
||||
service.unit_label && service.unit_label.slice(0, 3) === "per"
|
||||
? service.unit_label.slice(3)
|
||||
: service.unit_label
|
||||
}`;
|
||||
|
||||
return [fees, feesLabel, cost];
|
||||
};
|
||||
|
||||
const setProductsInfo = (data: CurrentUserData) => {
|
||||
const { products } = data.subscription || {};
|
||||
|
||||
const services = serviceData.map((service) => {
|
||||
const { quantity } = products
|
||||
? products.find((item) => item.name === service.name) || {}
|
||||
: { quantity: null };
|
||||
const [fees, feesLabel, cost] = getServicePrice(service, quantity || 0);
|
||||
return {
|
||||
...service,
|
||||
capacity: quantity || 0,
|
||||
invalid: false,
|
||||
fees,
|
||||
feesLabel,
|
||||
cost,
|
||||
visible: hasValue(quantity) && quantity > 0,
|
||||
};
|
||||
});
|
||||
|
||||
setServiceData(services);
|
||||
setOriginalServiceData([...services]);
|
||||
};
|
||||
|
||||
const updateServiceData = (
|
||||
index: number,
|
||||
key: string,
|
||||
value: typeof serviceData[number][keyof ServiceData]
|
||||
) => {
|
||||
setServiceData(
|
||||
serviceData.map((g, i) =>
|
||||
i === index
|
||||
? {
|
||||
...g,
|
||||
[key]: value,
|
||||
...(key === "capacity" && { cost: Number(value) * g.fees }),
|
||||
}
|
||||
: g
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (priceInfo) {
|
||||
initFeesAndCost(priceInfo);
|
||||
}
|
||||
|
||||
if (userData && priceInfo) {
|
||||
setProductsInfo(userData);
|
||||
}
|
||||
}, [priceInfo, userData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModifySubscription && originalServiceData.length > 0) {
|
||||
setIsDisableSubmitButton(
|
||||
serviceData[0].capacity === originalServiceData[0].capacity &&
|
||||
serviceData[1].capacity === originalServiceData[1].capacity
|
||||
);
|
||||
}
|
||||
setTotal(serviceData.reduce((res, service) => res + service.cost || 0, 0));
|
||||
}, [serviceData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">
|
||||
{isModifySubscription
|
||||
? "Configure Your Subscription"
|
||||
: "Upgrade your Subscription"}
|
||||
</H1>
|
||||
{isShowModalLoader && (
|
||||
<ModalLoader>
|
||||
<P>
|
||||
Your requested changes are being processed. Please do not leave the
|
||||
page or hit the back button until complete.
|
||||
</P>
|
||||
</ModalLoader>
|
||||
)}
|
||||
{isReviewChanges && !isShowModalLoader && (
|
||||
<Modal
|
||||
handleCancel={() => {
|
||||
setIsReviewChanges(false);
|
||||
setIsDisableSubmitButton(false);
|
||||
}}
|
||||
handleSubmit={handleReviewChangeSubmit}
|
||||
>
|
||||
<H1 className="h4">Confirm Changes</H1>
|
||||
<P>
|
||||
By pressing{" "}
|
||||
<span>
|
||||
<strong>Confirm</strong>
|
||||
</span>{" "}
|
||||
below, your plan will be immediately adjusted to the following
|
||||
levels:
|
||||
</P>
|
||||
<ul className="m">
|
||||
<li>{`- ${serviceData[0].capacity} simultaneous calls`}</li>
|
||||
{userData?.account && userData?.account.device_to_call_ratio && (
|
||||
<li>{`- ${
|
||||
userData?.account.device_to_call_ratio *
|
||||
(serviceData[0].capacity + serviceData[1].capacity)
|
||||
} registered devices`}</li>
|
||||
)}
|
||||
</ul>
|
||||
<P>
|
||||
{(billingCharge?.prorated_cost || 0) > 0 &&
|
||||
`Your new monthly charge will be $${
|
||||
(billingCharge?.monthly_cost || 0) / 100
|
||||
}, and you will immediately be charged a one-time prorated amount of $${
|
||||
(billingCharge?.prorated_cost || 0) / 100
|
||||
} to cover the remainder of the current billing period.`}
|
||||
{billingCharge?.prorated_cost === 0 &&
|
||||
`Your monthly charge will be $${
|
||||
(billingCharge.monthly_cost || 0) / 100
|
||||
}.`}
|
||||
{(billingCharge?.prorated_cost || 0) < 0 &&
|
||||
`Your new monthly charge will be $${
|
||||
(billingCharge?.monthly_cost || 0) / 100
|
||||
}, and you will receive a credit of $${
|
||||
-(billingCharge?.prorated_cost || 0) / 100
|
||||
} on your next invoice to reflect changes made during the current billing period.`}
|
||||
</P>
|
||||
</Modal>
|
||||
)}
|
||||
{isReturnToFreePlan && !isShowModalLoader && (
|
||||
<Modal
|
||||
handleCancel={() => setIsReturnToFreePlan(false)}
|
||||
handleSubmit={handleReturnToFreePlan}
|
||||
>
|
||||
<H1 className="h4">Return to Free Plan</H1>
|
||||
<P>
|
||||
Returning to the free plan will reduce your capacity to a maximum of
|
||||
1 simultaneous call session and 1 registered device. Your current
|
||||
plan and capacity will continue through the rest of the billing
|
||||
cycle and your plan change will take effect at the beginning of the
|
||||
next billing cycle. Are you sure you want to continue?
|
||||
</P>
|
||||
</Modal>
|
||||
)}
|
||||
<Section slim>
|
||||
<form className="form form--internal" onSubmit={handleSubmit}>
|
||||
<div className="grid grid--col4--users">
|
||||
<div className="grid__row grid__th">
|
||||
<div>Service</div>
|
||||
<div>Capacity</div>
|
||||
<div>Price</div>
|
||||
<div>Cost</div>
|
||||
</div>
|
||||
|
||||
{serviceData &&
|
||||
serviceData
|
||||
.filter((service) => service.visible)
|
||||
.map((service, idx) => (
|
||||
<React.Fragment key={`subscription-${idx}`}>
|
||||
<div className="grid__row">
|
||||
<div>
|
||||
<label htmlFor={service.name || ""}>
|
||||
{service.service}
|
||||
<span>*</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
id="tech_prefix"
|
||||
name="tech_prefix"
|
||||
type="number"
|
||||
value={service.capacity}
|
||||
required
|
||||
min={service.min}
|
||||
max={service.max}
|
||||
onChange={(e) => {
|
||||
updateServiceData(
|
||||
idx,
|
||||
"capacity",
|
||||
e.target.value ? Number(e.target.value) : ""
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<em>{service.feesLabel}</em>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<P>
|
||||
<strong>
|
||||
{CurrencySymbol[service.currency || "usd"]}
|
||||
{service.cost}
|
||||
</strong>
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{serviceData[0].capacity !== 0 && !serviceData[1].visible && (
|
||||
<>
|
||||
<div className="grid__row">
|
||||
<label htmlFor="max_concurrent_call_sessons">
|
||||
{`With ${
|
||||
serviceData[0].capacity
|
||||
} call sessions you can register ${
|
||||
serviceData[0].capacity *
|
||||
(userData?.account?.device_to_call_ratio || 0)
|
||||
} concurrent devices`}
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
mainStyle="hollow"
|
||||
onClick={() =>
|
||||
setServiceData((prev) => {
|
||||
prev[1].visible = true;
|
||||
return [...prev];
|
||||
})
|
||||
}
|
||||
>
|
||||
Would you like to purchase additional device
|
||||
registrations?
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="grid__row">
|
||||
<div>
|
||||
<label htmlFor="total">Total Monthly Cost</label>
|
||||
</div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div>
|
||||
<P>
|
||||
<strong>
|
||||
{CurrencySymbol[serviceData[0].currency || "usd"]}
|
||||
{total}
|
||||
</strong>
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
{!isModifySubscription && (
|
||||
<fieldset>
|
||||
<label htmlFor="total">Payment Information</label>
|
||||
<div className="grid__row">
|
||||
<div></div>
|
||||
<div>
|
||||
<PaymentElement />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
<fieldset>
|
||||
<>
|
||||
<div className={isModifySubscription ? "mast" : ""}>
|
||||
{isModifySubscription && (
|
||||
<ButtonGroup right>
|
||||
<Button
|
||||
type="button"
|
||||
subStyle="grey"
|
||||
mainStyle="hollow"
|
||||
onClick={() => setIsReturnToFreePlan(true)}
|
||||
small
|
||||
>
|
||||
Return to free plan
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
|
||||
<ButtonGroup right>
|
||||
<Button
|
||||
subStyle="grey"
|
||||
as={Link}
|
||||
to={`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`}
|
||||
small
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isDisableSubmitButton} small>
|
||||
{isModifySubscription
|
||||
? "Review Changes"
|
||||
: `Pay ${CurrencySymbol[serviceData[0].currency || "usd"]}
|
||||
${total} and Upgrade to Paid Plan`}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionForm;
|
||||
27
src/containers/internal/views/accounts/subscription.tsx
Normal file
27
src/containers/internal/views/accounts/subscription.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import { STRIPE_PUBLISHABLE_KEY } from "src/api/constants";
|
||||
|
||||
import { Elements } from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import SubscriptionForm from "./subscription-form";
|
||||
|
||||
export const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY);
|
||||
|
||||
export const Subscription = () => {
|
||||
return (
|
||||
<>
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
mode: "setup",
|
||||
currency: "usd",
|
||||
paymentMethodCreation: "manual",
|
||||
}}
|
||||
>
|
||||
<SubscriptionForm />
|
||||
</Elements>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Subscription;
|
||||
@@ -8,6 +8,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { MSG_SOMETHING_WRONG } from "src/constants";
|
||||
|
||||
import { ROUTE_LOGIN } from "src/router/routes";
|
||||
import { toastSuccess } from "src/store";
|
||||
|
||||
export const ForgotPassword = () => {
|
||||
const [message, setMessage] = useState("");
|
||||
@@ -22,6 +23,9 @@ export const ForgotPassword = () => {
|
||||
postForgotPassword({ email })
|
||||
.then((response) => {
|
||||
if (response.status === StatusCodes.NO_CONTENT) {
|
||||
toastSuccess(
|
||||
"A password reset email has been sent to your email. Please check your inbox (and, possibly, spam folder) and follow the instructions to reset your password."
|
||||
);
|
||||
navigate(ROUTE_LOGIN);
|
||||
} else {
|
||||
setMessage(MSG_SOMETHING_WRONG);
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useEffect, useState } from "react";
|
||||
import { Button, H1 } from "@jambonz/ui-kit";
|
||||
import { useLocation, Navigate, Link } from "react-router-dom";
|
||||
|
||||
import { toastError, toastSuccess, useSelectState } from "src/store";
|
||||
import { useAuth } from "src/router/auth";
|
||||
import { toastError, toastSuccess } from "src/store";
|
||||
import { getToken, parseJwt, useAuth } from "src/router/auth";
|
||||
import {
|
||||
SESS_FLASH_MSG,
|
||||
SESS_OLD_PASSWORD,
|
||||
@@ -13,15 +13,26 @@ import { Passwd, Message } from "src/components/forms";
|
||||
import {
|
||||
ROUTE_INTERNAL_ACCOUNTS,
|
||||
ROUTE_CREATE_PASSWORD,
|
||||
ROUTE_INTERNAL_APPLICATIONS,
|
||||
ROUTE_FORGOT_PASSWORD,
|
||||
ROUTE_REGISTER,
|
||||
} from "src/router/routes";
|
||||
import { USER_ACCOUNT, ENABLE_FORGOT_PASSWORD } from "src/api/constants";
|
||||
import {
|
||||
USER_ACCOUNT,
|
||||
ENABLE_FORGOT_PASSWORD,
|
||||
ENABLE_HOSTED_SYSTEM,
|
||||
} from "src/api/constants";
|
||||
import { Icons } from "src/components";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { setLocationBeforeOauth, setOauthState } from "src/store/localStore";
|
||||
import { getGithubOauthUrl, getGoogleOauthUrl } from "./utils";
|
||||
import { UserData } from "src/store/types";
|
||||
|
||||
export const Login = () => {
|
||||
const state = uuid();
|
||||
setOauthState(state);
|
||||
setLocationBeforeOauth("/sign-in");
|
||||
const { signin, authorized } = useAuth();
|
||||
const location = useLocation();
|
||||
const user = useSelectState("user");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
@@ -56,13 +67,13 @@ export const Login = () => {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const userData: UserData = parseJwt(getToken());
|
||||
return (
|
||||
<Navigate
|
||||
to={
|
||||
user?.scope !== USER_ACCOUNT
|
||||
userData?.scope !== USER_ACCOUNT
|
||||
? ROUTE_INTERNAL_ACCOUNTS
|
||||
: ROUTE_INTERNAL_APPLICATIONS
|
||||
: `${ROUTE_INTERNAL_ACCOUNTS}/${userData.account_sid}/edit`
|
||||
}
|
||||
state={{ from: location }}
|
||||
replace
|
||||
@@ -91,13 +102,36 @@ export const Login = () => {
|
||||
/>
|
||||
{message && <Message message={message} />}
|
||||
<Button type="submit">Log in</Button>
|
||||
{ENABLE_FORGOT_PASSWORD && (
|
||||
<div>
|
||||
<Link to={ROUTE_FORGOT_PASSWORD} title="Forgot Password">
|
||||
<p>Forgot Password</p>
|
||||
</Link>
|
||||
{(ENABLE_FORGOT_PASSWORD || ENABLE_HOSTED_SYSTEM) && (
|
||||
<div className={ENABLE_HOSTED_SYSTEM ? "mast" : ""}>
|
||||
{ENABLE_HOSTED_SYSTEM && (
|
||||
<Link to={ROUTE_REGISTER} title="Forgot Password">
|
||||
<p>Register</p>
|
||||
</Link>
|
||||
)}
|
||||
{ENABLE_FORGOT_PASSWORD && (
|
||||
<Link to={ROUTE_FORGOT_PASSWORD} title="Forgot Password">
|
||||
<p>Forgot Password</p>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ENABLE_HOSTED_SYSTEM && (
|
||||
<>
|
||||
<a href={getGoogleOauthUrl(state)} className="btn btn--hollow">
|
||||
<div className="mast">
|
||||
<Icons.Youtube />
|
||||
<span>Sign In With Google</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href={getGithubOauthUrl(state)} className="btn btn--hollow">
|
||||
<div className="mast">
|
||||
<Icons.GitHub />
|
||||
<span>Sign In With Github</span>
|
||||
</div>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
|
||||
95
src/containers/login/oauth-callback.tsx
Normal file
95
src/containers/login/oauth-callback.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { getMe, postRegister } from "src/api";
|
||||
import {
|
||||
DEFAULT_SERVICE_PROVIDER_SID,
|
||||
GITHUB_CLIENT_ID,
|
||||
GOOGLE_CLIENT_ID,
|
||||
BASE_URL,
|
||||
} from "src/api/constants";
|
||||
import { Spinner } from "src/components";
|
||||
import { setToken } from "src/router/auth";
|
||||
import {
|
||||
ROUTE_INTERNAL_ACCOUNTS,
|
||||
ROUTE_LOGIN,
|
||||
ROUTE_REGISTER,
|
||||
ROUTE_REGISTER_SUB_DOMAIN,
|
||||
} from "src/router/routes";
|
||||
import { toastError } from "src/store";
|
||||
import {
|
||||
getLocationBeforeOauth,
|
||||
getOauthState,
|
||||
removeLocationBeforeOauth,
|
||||
removeOauthState,
|
||||
setRootDomain,
|
||||
} from "src/store/localStore";
|
||||
|
||||
export const OauthCallback = () => {
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const code = queryParams.get("code");
|
||||
const newState = queryParams.get("state");
|
||||
const originalState = getOauthState();
|
||||
const previousLocation = getLocationBeforeOauth();
|
||||
const { provider } = useParams();
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
if (provider !== "github" && provider !== "google") {
|
||||
toastError(`${provider} is not a valid OAuth provider`);
|
||||
navigate(ROUTE_LOGIN);
|
||||
return;
|
||||
}
|
||||
if (!code || !originalState || !newState || newState !== originalState) {
|
||||
toastError("Invalid state");
|
||||
navigate(ROUTE_LOGIN);
|
||||
}
|
||||
|
||||
let oauth2_client_id;
|
||||
let oauth2_redirect_uri;
|
||||
|
||||
if (provider === "github") {
|
||||
oauth2_client_id = GITHUB_CLIENT_ID;
|
||||
oauth2_redirect_uri = `${BASE_URL}/oauth-callback/github`;
|
||||
} else if (provider === "google") {
|
||||
oauth2_client_id = GOOGLE_CLIENT_ID;
|
||||
oauth2_redirect_uri = `${BASE_URL}/oauth-callback/google`;
|
||||
}
|
||||
|
||||
removeOauthState();
|
||||
removeLocationBeforeOauth();
|
||||
|
||||
postRegister({
|
||||
service_provider_sid: DEFAULT_SERVICE_PROVIDER_SID,
|
||||
provider,
|
||||
oauth2_code: code || "",
|
||||
oauth2_state: originalState,
|
||||
oauth2_client_id,
|
||||
oauth2_redirect_uri,
|
||||
locationBeforeAuth: previousLocation,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setToken(json.jwt);
|
||||
setRootDomain(json.root_domain);
|
||||
if (previousLocation === "/register") {
|
||||
navigate(ROUTE_REGISTER_SUB_DOMAIN);
|
||||
} else {
|
||||
getMe()
|
||||
.then(({ json: me }) => {
|
||||
if (!me.account?.sip_realm) {
|
||||
navigate(ROUTE_REGISTER_SUB_DOMAIN);
|
||||
} else {
|
||||
navigate(`${ROUTE_INTERNAL_ACCOUNTS}/${json.account_sid}/edit`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
navigate(ROUTE_REGISTER);
|
||||
});
|
||||
}, []);
|
||||
return <Spinner />;
|
||||
};
|
||||
|
||||
export default OauthCallback;
|
||||
67
src/containers/login/register-email.tsx
Normal file
67
src/containers/login/register-email.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Button, H1 } from "@jambonz/ui-kit";
|
||||
import React, { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { postRegister } from "src/api";
|
||||
import { DEFAULT_SERVICE_PROVIDER_SID } from "src/api/constants";
|
||||
import { Passwd } from "src/components/forms";
|
||||
import { ROUTE_LOGIN, ROUTE_REGISTER_EMAIL_VERIFY } from "src/router/routes";
|
||||
import { generateActivationCode } from "./utils";
|
||||
import { setToken } from "src/router/auth";
|
||||
import { toastError } from "src/store";
|
||||
import { setRootDomain } from "src/store/localStore";
|
||||
|
||||
export const RegisterEmail = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const email_activation_code = generateActivationCode();
|
||||
postRegister({
|
||||
service_provider_sid: DEFAULT_SERVICE_PROVIDER_SID,
|
||||
provider: "local",
|
||||
email,
|
||||
password,
|
||||
email_activation_code,
|
||||
inviteCode: undefined, // reserved for inviteCode.
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setToken(json.jwt);
|
||||
setRootDomain(json.root_domain);
|
||||
navigate(ROUTE_REGISTER_EMAIL_VERIFY);
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Register</H1>
|
||||
|
||||
<form className="form form--login" onSubmit={handleSubmit}>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
name="email"
|
||||
placeholder="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Passwd
|
||||
required
|
||||
name="password"
|
||||
value={password}
|
||||
placeholder="Password"
|
||||
setValue={setPassword}
|
||||
/>
|
||||
<Button type="submit">Continue →</Button>
|
||||
<Link to={ROUTE_LOGIN} title="Go back">
|
||||
<p>Go back</p>
|
||||
</Link>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterEmail;
|
||||
54
src/containers/login/register-verify-email.tsx
Normal file
54
src/containers/login/register-verify-email.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Button, H1, MS } from "@jambonz/ui-kit";
|
||||
import React, { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { putActivationCode } from "src/api";
|
||||
import { getToken, parseJwt } from "src/router/auth";
|
||||
import {
|
||||
ROUTE_REGISTER_EMAIL,
|
||||
ROUTE_REGISTER_SUB_DOMAIN,
|
||||
} from "src/router/routes";
|
||||
import { toastError } from "src/store";
|
||||
import { UserData } from "src/store/types";
|
||||
|
||||
export const EmailVerify = () => {
|
||||
const [code, setCode] = useState("");
|
||||
const userData: UserData = parseJwt(getToken());
|
||||
const navigate = useNavigate();
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
putActivationCode(code, {
|
||||
user_sid: userData.user_sid,
|
||||
type: "email",
|
||||
})
|
||||
.then(() => {
|
||||
navigate(ROUTE_REGISTER_SUB_DOMAIN);
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Register</H1>
|
||||
|
||||
<form className="form form--login" onSubmit={handleSubmit}>
|
||||
<MS>Please enter the code we just sent to your email</MS>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
name="code"
|
||||
placeholder="Verification Code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
<Button type="submit">Continue →</Button>
|
||||
<Link to={ROUTE_REGISTER_EMAIL} title="Go back">
|
||||
<p>Go back</p>
|
||||
</Link>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailVerify;
|
||||
67
src/containers/login/register.tsx
Normal file
67
src/containers/login/register.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { getGithubOauthUrl, getGoogleOauthUrl } from "./utils";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { setLocationBeforeOauth, setOauthState } from "src/store/localStore";
|
||||
import { Icons } from "src/components";
|
||||
import { Button, H1 } from "@jambonz/ui-kit";
|
||||
import { PRIVACY_POLICY, TERMS_OF_SERVICE } from "src/api/constants";
|
||||
import { Checkzone } from "src/components/forms";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ROUTE_REGISTER_EMAIL } from "src/router/routes";
|
||||
|
||||
export const Register = () => {
|
||||
const state = uuid();
|
||||
setOauthState(state);
|
||||
setLocationBeforeOauth("/register");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Register</H1>
|
||||
|
||||
<form className="form form--login" onSubmit={handleSubmit}>
|
||||
<Checkzone
|
||||
hidden
|
||||
name="is_accepted"
|
||||
label=""
|
||||
labelNode={
|
||||
<div>
|
||||
I accept the
|
||||
<a href={TERMS_OF_SERVICE}>
|
||||
<span> Terms of Service </span>
|
||||
</a>
|
||||
and have read the
|
||||
<a href={PRIVACY_POLICY}>
|
||||
<span> Privacy Policy</span>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
initialCheck={false}
|
||||
>
|
||||
<Button as={Link} to={ROUTE_REGISTER_EMAIL} mainStyle="hollow">
|
||||
<div className="mast">
|
||||
<Icons.Mail />
|
||||
<span>Sign Up With Email</span>
|
||||
</div>
|
||||
</Button>
|
||||
<a href={getGoogleOauthUrl(state)} className="btn btn--hollow">
|
||||
<div className="mast">
|
||||
<Icons.Youtube />
|
||||
<span>Sign Up With Google</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href={getGithubOauthUrl(state)} className="btn btn--hollow">
|
||||
<div className="mast">
|
||||
<Icons.GitHub />
|
||||
<span>Sign Up With Github</span>
|
||||
</div>
|
||||
</a>
|
||||
</Checkzone>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
97
src/containers/login/reset-password.tsx
Normal file
97
src/containers/login/reset-password.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Button, H1 } from "@jambonz/ui-kit";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { postChangepassword, postSignIn } from "src/api";
|
||||
import { Message, Passwd } from "src/components/forms";
|
||||
import { setToken } from "src/router/auth";
|
||||
import { ROUTE_LOGIN } from "src/router/routes";
|
||||
import { toastError, toastSuccess } from "src/store";
|
||||
|
||||
export const ResetPassword = () => {
|
||||
const params = useParams();
|
||||
const resetId = params.id;
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState("");
|
||||
const [isDisableSubmitButton, setIsDisableSubmitButton] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage("");
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
setMessage(
|
||||
"The confirmation password does not match the new password. Please ensure both passwords are identical."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
setMessage("The password must be at least 7 characters long.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/[a-zA-Z]/.test(newPassword)) {
|
||||
setMessage("Password must contain a letter.");
|
||||
}
|
||||
|
||||
setIsDisableSubmitButton(true);
|
||||
postChangepassword({
|
||||
old_password: resetId,
|
||||
new_password: newPassword,
|
||||
})
|
||||
.then(() => {
|
||||
toastSuccess("New password was successfully set.");
|
||||
setToken("");
|
||||
navigate(ROUTE_LOGIN);
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
postSignIn({
|
||||
link: resetId,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setToken(json.jwt || "");
|
||||
})
|
||||
.catch((error) => toastError(error.msg));
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Reset Password</H1>
|
||||
<form className="form form--login" onSubmit={handleSubmit}>
|
||||
<label htmlFor="new_password">New password</label>
|
||||
<Passwd
|
||||
id="new_password"
|
||||
name="new_password"
|
||||
value={newPassword}
|
||||
placeholder="New password"
|
||||
required
|
||||
onChange={(e) => {
|
||||
setNewPassword(e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<label htmlFor="confirm_new_password">Confirm new password</label>
|
||||
<Passwd
|
||||
id="confirm_new_password"
|
||||
name="confirm_new_password"
|
||||
value={confirmNewPassword}
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
onChange={(e) => {
|
||||
setConfirmNewPassword(e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
{message && <Message message={message} />}
|
||||
<Button type="submit" disabled={isDisableSubmitButton}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
63
src/containers/login/sub-domain.tsx
Normal file
63
src/containers/login/sub-domain.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Button, H1, MS } from "@jambonz/ui-kit";
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getAvailability, postSipRealms } from "src/api";
|
||||
import { Message } from "src/components/forms";
|
||||
import { getToken, parseJwt } from "src/router/auth";
|
||||
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
|
||||
import { getRootDomain } from "src/store/localStore";
|
||||
import { UserData } from "src/store/types";
|
||||
|
||||
export const RegisterChooseSubdomain = () => {
|
||||
const [name, setName] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const rootDomain = getRootDomain();
|
||||
const userData: UserData = parseJwt(getToken());
|
||||
const navigate = useNavigate();
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage("");
|
||||
|
||||
getAvailability(`${name}.${rootDomain}`)
|
||||
.then(({ json }) => {
|
||||
if (!json.available) {
|
||||
setErrorMessage("That subdomain is not available.");
|
||||
return;
|
||||
}
|
||||
postSipRealms(userData.account_sid || "", `${name}.${rootDomain}`)
|
||||
.then(() => {
|
||||
navigate(`${ROUTE_INTERNAL_ACCOUNTS}/${userData.account_sid}/edit`);
|
||||
})
|
||||
.catch((error) => {
|
||||
setErrorMessage(error.msg);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setErrorMessage(error.msg);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<H1 className="h2">Choose a subdomain</H1>
|
||||
|
||||
<form className="form form--login" onSubmit={handleSubmit}>
|
||||
{errorMessage && <Message message={errorMessage} />}
|
||||
<MS>
|
||||
This will be the FQDN where your carrier will send calls, and where
|
||||
you can register devices to. This can be changed at any time.
|
||||
</MS>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder="Your Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Button type="submit">Complete Registration →</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterChooseSubdomain;
|
||||
24
src/containers/login/utils.ts
Normal file
24
src/containers/login/utils.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
GITHUB_CLIENT_ID,
|
||||
GOOGLE_CLIENT_ID,
|
||||
BASE_URL,
|
||||
} from "src/api/constants";
|
||||
|
||||
export const getGithubOauthUrl = (state: string) => {
|
||||
return `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&state=${state}&scope=user:email&allow_signup=false`;
|
||||
};
|
||||
|
||||
export const getGoogleOauthUrl = (state: string) => {
|
||||
return `https://accounts.google.com/o/oauth2/v2/auth?scope=email+profile&access_type=offline&include_granted_scopes=true&response_type=code&state=${state}&redirect_uri=${BASE_URL}/oauth-callback/google&client_id=${GOOGLE_CLIENT_ID}`;
|
||||
};
|
||||
|
||||
const length = 6;
|
||||
const digit = () => Math.floor(Math.random() * 10);
|
||||
|
||||
export function generateActivationCode() {
|
||||
let activationCode = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
activationCode += digit();
|
||||
}
|
||||
return activationCode;
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
import React, { useContext } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { postLogin, postLogout } from "src/api";
|
||||
import { getMe, postLogin, postLogout } from "src/api";
|
||||
import { StatusCodes } from "src/api/types";
|
||||
import {
|
||||
ROUTE_CREATE_PASSWORD,
|
||||
ROUTE_INTERNAL_ACCOUNTS,
|
||||
ROUTE_INTERNAL_APPLICATIONS,
|
||||
ROUTE_LOGIN,
|
||||
ROUTE_REGISTER_SUB_DOMAIN,
|
||||
} from "./routes";
|
||||
import {
|
||||
SESS_OLD_PASSWORD,
|
||||
@@ -23,8 +23,13 @@ import {
|
||||
} from "src/constants";
|
||||
|
||||
import type { UserLogin } from "src/api/types";
|
||||
import { USER_ACCOUNT } from "src/api/constants";
|
||||
import { ENABLE_HOSTED_SYSTEM, USER_ACCOUNT } from "src/api/constants";
|
||||
import type { UserData } from "src/store/types";
|
||||
import { toastError } from "src/store";
|
||||
import {
|
||||
removeLocationBeforeOauth,
|
||||
removeOauthState,
|
||||
} from "src/store/localStore";
|
||||
|
||||
interface SignIn {
|
||||
(username: string, password: string): Promise<UserLogin>;
|
||||
@@ -103,7 +108,23 @@ export const useProvideAuth = (): AuthStateContext => {
|
||||
setToken(token);
|
||||
userData = parseJwt(token);
|
||||
|
||||
if (response.json.force_change) {
|
||||
if (ENABLE_HOSTED_SYSTEM) {
|
||||
getMe()
|
||||
.then(({ json }) => {
|
||||
if (!json.account?.sip_realm) {
|
||||
navigate(ROUTE_REGISTER_SUB_DOMAIN);
|
||||
} else {
|
||||
navigate(
|
||||
userData.scope !== USER_ACCOUNT
|
||||
? ROUTE_INTERNAL_ACCOUNTS
|
||||
: `${ROUTE_INTERNAL_ACCOUNTS}/${userData.account_sid}/edit`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toastError(error.msg);
|
||||
});
|
||||
} else if (response.json.force_change) {
|
||||
sessionStorage.setItem(SESS_USER_SID, response.json.user_sid);
|
||||
sessionStorage.setItem(SESS_OLD_PASSWORD, password);
|
||||
navigate(ROUTE_CREATE_PASSWORD);
|
||||
@@ -111,7 +132,7 @@ export const useProvideAuth = (): AuthStateContext => {
|
||||
navigate(
|
||||
userData.scope !== USER_ACCOUNT
|
||||
? ROUTE_INTERNAL_ACCOUNTS
|
||||
: ROUTE_INTERNAL_APPLICATIONS
|
||||
: `${ROUTE_INTERNAL_ACCOUNTS}/${userData.account_sid}/edit`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,19 +152,23 @@ export const useProvideAuth = (): AuthStateContext => {
|
||||
}
|
||||
|
||||
reject(MSG_SOMETHING_WRONG);
|
||||
})
|
||||
.finally(() => {
|
||||
removeOauthState();
|
||||
removeLocationBeforeOauth();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const signout = () => {
|
||||
window.location.href = ROUTE_LOGIN;
|
||||
return new Promise((resolve, reject) => {
|
||||
postLogout()
|
||||
.then((response) => {
|
||||
if (response.status === StatusCodes.OK) {
|
||||
if (response.status === StatusCodes.NO_CONTENT) {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
sessionStorage.setItem(SESS_FLASH_MSG, MSG_LOGGED_OUT);
|
||||
window.location.href = ROUTE_LOGIN;
|
||||
resolve(response.json);
|
||||
}
|
||||
})
|
||||
@@ -151,6 +176,7 @@ export const useProvideAuth = (): AuthStateContext => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
sessionStorage.setItem(SESS_FLASH_MSG, MSG_LOGGED_OUT);
|
||||
window.location.href = ROUTE_LOGIN;
|
||||
if (error) {
|
||||
reject(error);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import { useSelectState } from "src/store";
|
||||
import { Login, Layout as LoginLayout } from "src/containers/login";
|
||||
import { Layout as InternalLayout } from "src/containers/internal";
|
||||
import { NotFound } from "src/containers/notfound";
|
||||
import { ENABLE_FORGOT_PASSWORD } from "src/api/constants";
|
||||
import {
|
||||
ENABLE_HOSTED_SYSTEM,
|
||||
ENABLE_FORGOT_PASSWORD,
|
||||
} from "src/api/constants";
|
||||
|
||||
/** Login */
|
||||
import CreatePassword from "src/containers/login/create-password";
|
||||
@@ -45,6 +48,15 @@ import LcrsEdit from "src/containers/internal/views/least-cost-routing/edit";
|
||||
import Clients from "src/containers/internal/views/clients";
|
||||
import ClientsAdd from "src/containers/internal/views/clients/add";
|
||||
import ClientsEdit from "src/containers/internal/views/clients/edit";
|
||||
import OauthCallback from "src/containers/login/oauth-callback";
|
||||
import Register from "src/containers/login/register";
|
||||
import RegisterEmail from "src/containers/login/register-email";
|
||||
import EmailVerify from "src/containers/login/register-verify-email";
|
||||
import RegisterChooseSubdomain from "src/containers/login/sub-domain";
|
||||
import Subscription from "src/containers/internal/views/accounts/subscription";
|
||||
import ManagePayment from "src/containers/internal/views/accounts/manage-payment";
|
||||
import EditSipRealm from "src/containers/internal/views/accounts/edit-sip-realm";
|
||||
import ResetPassword from "src/containers/login/reset-password";
|
||||
|
||||
export const Router = () => {
|
||||
const toast = useSelectState("toast");
|
||||
@@ -65,9 +77,29 @@ export const Router = () => {
|
||||
}
|
||||
/>
|
||||
{ENABLE_FORGOT_PASSWORD && (
|
||||
<Route path="forgot-password" element={<ForgotPassword />} />
|
||||
<>
|
||||
<Route path="forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="reset-password/:id" element={<ResetPassword />} />
|
||||
</>
|
||||
)}
|
||||
{ENABLE_HOSTED_SYSTEM && (
|
||||
<>
|
||||
<Route path="register" element={<Register />} />
|
||||
<Route path="register/email" element={<RegisterEmail />} />
|
||||
<Route
|
||||
path="register/email/verify-your-email"
|
||||
element={<EmailVerify />}
|
||||
/>
|
||||
<Route
|
||||
path="register/choose-a-subdomain"
|
||||
element={<RegisterChooseSubdomain />}
|
||||
/>
|
||||
<Route
|
||||
path="oauth-callback/:provider"
|
||||
element={<OauthCallback />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 404 page not found */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
@@ -90,6 +122,26 @@ export const Router = () => {
|
||||
path="accounts/:account_sid/edit"
|
||||
element={<AccountEdit />}
|
||||
/>
|
||||
{ENABLE_HOSTED_SYSTEM && (
|
||||
<>
|
||||
<Route
|
||||
path="accounts/:account_sid/subscription"
|
||||
element={<Subscription />}
|
||||
/>
|
||||
<Route
|
||||
path="accounts/:account_sid/manage-payment"
|
||||
element={<ManagePayment />}
|
||||
/>
|
||||
<Route
|
||||
path="accounts/:account_sid/modify-subscription"
|
||||
element={<Subscription />}
|
||||
/>
|
||||
<Route
|
||||
path="accounts/:account_sid/sip-realm/edit"
|
||||
element={<EditSipRealm />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Route path="applications" element={<Applications />} />
|
||||
<Route path="applications/add" element={<ApplicationAdd />} />
|
||||
<Route
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const ROUTE_LOGIN = "/";
|
||||
export const ROUTE_REGISTER = "/register";
|
||||
export const ROUTE_REGISTER_EMAIL = "/register/email";
|
||||
export const ROUTE_REGISTER_EMAIL_VERIFY = "/register/email/verify-your-email";
|
||||
export const ROUTE_REGISTER_SUB_DOMAIN = "/register/choose-a-subdomain";
|
||||
export const ROUTE_CREATE_PASSWORD = "/create-password";
|
||||
export const ROUTE_FORGOT_PASSWORD = "/forgot-password";
|
||||
export const ROUTE_INTERNAL_USERS = "/internal/users";
|
||||
|
||||
@@ -58,6 +58,48 @@ export const removeQueryFilter = () => {
|
||||
return localStorage.removeItem(storeQueryFilter);
|
||||
};
|
||||
|
||||
/**Oauth2 */
|
||||
const oauthStateKey = "oauth-state";
|
||||
export const getOauthState = () => {
|
||||
return localStorage.getItem(oauthStateKey) || "";
|
||||
};
|
||||
|
||||
export const setOauthState = (token: string) => {
|
||||
localStorage.setItem(oauthStateKey, token);
|
||||
};
|
||||
|
||||
export const removeOauthState = () => {
|
||||
return localStorage.removeItem(oauthStateKey);
|
||||
};
|
||||
|
||||
const locationBeforeOauthKey = "location-before-oauth";
|
||||
|
||||
export const getLocationBeforeOauth = () => {
|
||||
return localStorage.getItem(locationBeforeOauthKey) || "";
|
||||
};
|
||||
|
||||
export const setLocationBeforeOauth = (token: string) => {
|
||||
localStorage.setItem(locationBeforeOauthKey, token);
|
||||
};
|
||||
|
||||
export const removeLocationBeforeOauth = () => {
|
||||
return localStorage.removeItem(locationBeforeOauthKey);
|
||||
};
|
||||
|
||||
// Email register
|
||||
const rootDomainKey = "root-domain";
|
||||
export const setRootDomain = (domain: string) => {
|
||||
return localStorage.setItem(rootDomainKey, domain);
|
||||
};
|
||||
|
||||
export const getRootDomain = () => {
|
||||
return localStorage.getItem(rootDomainKey);
|
||||
};
|
||||
|
||||
export const removeRootDomain = () => {
|
||||
return localStorage.removeItem(rootDomainKey);
|
||||
};
|
||||
|
||||
/**
|
||||
* Methods to get/set the location from local storage
|
||||
*/
|
||||
|
||||
@@ -64,6 +64,9 @@ fieldset {
|
||||
> button {
|
||||
width: 100%;
|
||||
}
|
||||
> a {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.msg {
|
||||
width: 100%;
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
padding-right: ui-vars.$px02;
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
}
|
||||
@@ -112,4 +111,18 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--col4--users {
|
||||
.grid__row {
|
||||
grid-template-columns: [col] 30% [col] 40% [col] 25% [col] 5%;
|
||||
grid-template-rows: [row] auto [row] auto [row] [row] auto;
|
||||
display: grid;
|
||||
justify-content: space-between;
|
||||
|
||||
> div:last-child {
|
||||
text-align: left;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +173,13 @@ details {
|
||||
margin-top: ui-vars.$px02;
|
||||
}
|
||||
|
||||
.pre-grid-white {
|
||||
@extend .pre-grid;
|
||||
background-color: ui-vars.$white;
|
||||
color: ui-vars.$dark;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.pcap {
|
||||
margin-top: ui-vars.$px02;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user