change subdomain

main
alex 2024-09-06 17:15:11 +02:00
parent 8544f21b94
commit 9176e80363
9 changed files with 574 additions and 314 deletions

View File

@ -3,6 +3,8 @@ import DashboardLayout from "./core/components/DashboardLayout";
import {
darkMode,
primaryColor,
setBannerUrl,
setLogoUrl,
setPrimaryColor,
setUserAuthenticated,
userAuthenticated,
@ -46,6 +48,8 @@ function App() {
if (response) {
dispatch(setUserAuthenticated(true));
dispatch(setPrimaryColor(`#${response.Organization.PrimaryColor}`));
dispatch(setLogoUrl(response.Organization.LogoUrl));
dispatch(setBannerUrl(response.Organization.BannerUrl));
}
} catch (error) {}
})();

View File

@ -26,7 +26,7 @@ import {
lessonState,
} from "features/Lessons/LessonPageEditor/lessonPageEditorSlice";
import { Component, componentsGroups } from "features/Lessons/components";
import { darkMode } from "core/reducers/appSlice";
import { darkMode, logoUrl } from "core/reducers/appSlice";
import { DndContext, DragOverlay, useDraggable } from "@dnd-kit/core";
import { createPortal } from "react-dom";
import { LessonState } from "core/types/lesson";
@ -43,6 +43,7 @@ export function SideMenuContent() {
const dispatch = useDispatch();
const componentFirstRender = useSelector(sideMenuComponentFirstRender);
const appLogoUrl = useSelector(logoUrl);
const navigate = useNavigate();
@ -179,7 +180,15 @@ export function SideMenuContent() {
}}
></div>
<div style={{ overflowY: "scroll" }}>
<div>
<Flex justify="center" style={{ paddingBottom: 24, width: "100%" }}>
<img
src={`${Constants.STATIC_CONTENT_ADDRESS}${appLogoUrl}`}
alt="logo"
style={{ height: 80 }}
/>
</Flex>
<Menu
mode="inline"
onClick={(item) => navigate(item.key)}

View File

@ -6,6 +6,8 @@ export const appSlice = createSlice({
darkMode: false,
userAuthenticated: null,
primaryColor: "#1677FF",
logoUrl: "",
bannerUrl: "",
},
reducers: {
setDarkMode: (state, action) => {
@ -17,15 +19,29 @@ export const appSlice = createSlice({
setPrimaryColor: (state, action) => {
state.primaryColor = action.payload;
},
setLogoUrl: (state, action) => {
state.logoUrl = action.payload;
},
setBannerUrl: (state, action) => {
state.bannerUrl = action.payload;
},
},
selectors: {
darkMode: (state) => state.darkMode,
userAuthenticated: (state) => state.userAuthenticated,
primaryColor: (state) => state.primaryColor,
logoUrl: (state) => state.logoUrl,
bannerUrl: (state) => state.bannerUrl,
},
});
export const { setDarkMode, setUserAuthenticated, setPrimaryColor } =
appSlice.actions;
export const {
setDarkMode,
setUserAuthenticated,
setPrimaryColor,
setLogoUrl,
setBannerUrl,
} = appSlice.actions;
export const { darkMode, userAuthenticated, primaryColor } = appSlice.selectors;
export const { darkMode, userAuthenticated, primaryColor, logoUrl, bannerUrl } =
appSlice.selectors;

View File

@ -1,6 +1,10 @@
import { createApi } from "@reduxjs/toolkit/query/react";
import { baseQueryWithErrorHandling } from "core/helper/api";
import { TeamMember, OrganizationSettings } from "core/types/organization";
import {
TeamMember,
OrganizationSettings,
IsSubdomainAvailableResponse,
} from "core/types/organization";
export const organizationApi = createApi({
reducerPath: "organizationApi",
@ -25,6 +29,18 @@ export const organizationApi = createApi({
body: { PrimaryColor: primaryColor, CompanyName: companyName },
}),
}),
isSubdomainAvailable: builder.mutation({
query: (subdomain) => ({
url: `organization/subdomain/${subdomain}`,
method: "GET",
}),
}),
updateSubdomain: builder.mutation({
query: (subdomain) => ({
url: `organization/subdomain/${subdomain}`,
method: "PATCH",
}),
}),
}),
});
@ -32,4 +48,6 @@ export const {
useGetTeamQuery,
useGetOrganizationSettingsQuery,
useUpdateOrganizationSettingsMutation,
useIsSubdomainAvailableMutation,
useUpdateSubdomainMutation,
} = organizationApi;

View File

@ -14,3 +14,7 @@ export interface OrganizationSettings {
LogoUrl: string;
BannerUrl: string;
}
export interface IsSubdomainAvailableResponse {
Available: boolean;
}

View File

@ -1,7 +1,7 @@
import { Buffer } from 'buffer';
import { v4 as uuidv4 } from 'uuid';
import { Buffer } from "buffer";
import { v4 as uuidv4 } from "uuid";
const wssProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const wssProtocol = window.location.protocol === "https:" ? "wss://" : "ws://";
export const Constants = {
API_ADDRESS: `${window.location.protocol}//${window.location.hostname}/api/v1`,
@ -9,25 +9,34 @@ export const Constants = {
STATIC_CONTENT_ADDRESS: `${window.location.protocol}//${window.location.hostname}/api/static`,
ROUTE_PATHS: {
LESSIONS: {
ROOT: '/lessons',
PAGE: '/lessons/:lessonId',
PAGE_EDITOR: '/lessons/:lessonId/editor',
ROOT: "/lessons",
PAGE: "/lessons/:lessonId",
PAGE_EDITOR: "/lessons/:lessonId/editor",
},
ORGANIZATION_TEAM: '/team',
ORGANIZATION_TEAM_CREATE_USER: '/team/create-user',
ORGANIZATION_ROLES: '/roles',
ORGANIZATION_SETTINGS: '/organization',
ACCOUNT_SETTINGS: '/account',
WHATS_NEW: '/whats-new',
SUGGEST_FEATURE: '/suggest-feature',
CONTACT_SUPPORT: '/contact-support',
ORGANIZATION_TEAM: "/team",
ORGANIZATION_TEAM_CREATE_USER: "/team/create-user",
ORGANIZATION_ROLES: "/roles",
ORGANIZATION_SETTINGS: "/organization",
ACCOUNT_SETTINGS: "/account",
WHATS_NEW: "/whats-new",
SUGGEST_FEATURE: "/suggest-feature",
CONTACT_SUPPORT: "/contact-support",
},
STYLES: {
BLACK: '#000',
BLACK: "#000",
},
MAX_IMAGE_SIZE: 25 * 1024 * 1024, // 25MB
ACCEPTED_IMAGE_FILE_TYPES: ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'],
ACCEPTED_VIDEO_FILE_TYPES: ['video/mp4', 'video/webm', 'video/mkv'],
ACCEPTED_IMAGE_FILE_TYPES: [
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
],
ACCEPTED_VIDEO_FILE_TYPES: ["video/mp4", "video/webm", "video/mkv"],
GLOBALS: {
MIN_SUBDOMAIN_LENGTH: 3,
MAX_SUBDOMAIN_LENGTH: 32,
},
};
// used for sideMenu
@ -49,20 +58,20 @@ export function getImageUrl(imageName: string) {
export const BrowserTabSession = GetUuid();
export function getUserSessionFromLocalStorage() {
return localStorage.getItem('session');
return localStorage.getItem("session");
}
export function EncodeStringToBase64(value: string) {
return Buffer.from(value).toString('base64');
return Buffer.from(value).toString("base64");
}
export function DecodedBase64ToString(value: string) {
return Buffer.from(value, 'base64').toString();
return Buffer.from(value, "base64").toString();
}
export function handleLogout() {
localStorage.removeItem('session');
window.location.href = '/';
localStorage.removeItem("session");
window.location.href = "/";
}
export const myFetchContentType = {
@ -70,14 +79,14 @@ export const myFetchContentType = {
FORM_DATA: 1,
};
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
interface MyFetchOptions<TRequest = any, TResponse = any> {
url?: string;
method?: Method;
body?: TRequest | null;
headers?: Record<string, string>;
contentType?: 'JSON' | 'FORM_DATA';
contentType?: "JSON" | "FORM_DATA";
fetchUrl?: string;
ignoreUnauthorized?: boolean;
notificationApi?: any; // Passen Sie dies je nach Bedarf an
@ -85,26 +94,26 @@ interface MyFetchOptions<TRequest = any, TResponse = any> {
}
export function myFetch<TRequest = any, TResponse = any>({
url = '',
method = 'GET',
url = "",
method = "GET",
body = null,
headers = {},
contentType = 'JSON',
contentType = "JSON",
fetchUrl = Constants.API_ADDRESS,
ignoreUnauthorized = false,
notificationApi = null,
t = null,
}: MyFetchOptions<TRequest, TResponse>): Promise<TResponse> {
const getContentType = () => {
if (contentType === 'JSON') return 'application/json';
if (contentType === "JSON") return "application/json";
return 'multipart/form-data';
return "multipart/form-data";
};
const getBody = () => {
if (!body) return null;
if (contentType === 'JSON') return JSON.stringify(body);
if (contentType === "JSON") return JSON.stringify(body);
return body;
};
@ -118,15 +127,15 @@ export function myFetch<TRequest = any, TResponse = any>({
const requestOptions = {
method: method,
headers: {
'X-Authorization': getUserSessionFromLocalStorage() || '',
'Content-Type': getContentType(),
"X-Authorization": getUserSessionFromLocalStorage() || "",
"Content-Type": getContentType(),
...headers,
},
body: getBody(),
signal: signal,
};
if (fetchUrl === '') {
if (fetchUrl === "") {
fetchUrl = Constants.API_ADDRESS;
}
@ -135,7 +144,7 @@ export function myFetch<TRequest = any, TResponse = any>({
// if status is not in range 200-299
if (!response.ok) {
if (!ignoreUnauthorized && response.status === 401) {
console.error('Unauthorized');
console.error("Unauthorized");
// TODO: check here
//setUserSessionToLocalStorage("");
//window.location.href = "/";
@ -145,14 +154,14 @@ export function myFetch<TRequest = any, TResponse = any>({
}
// check if response is json
if (response.headers.get('content-type')?.includes('application/json')) {
if (response.headers.get("content-type")?.includes("application/json")) {
return response.json();
}
return response.text();
})
.catch(async (error) => {
console.error('Error', error);
console.error("Error", error);
// ignore errors here as they are handled in the components
if (error === 400) throw error;

View File

@ -1,26 +1,34 @@
import { Button, Card, Flex, Form, Input } from "antd";
import { Button, Flex, Form, Input, Modal } from "antd";
import HeaderBar from "../../core/components/Header";
import MyBanner from "../../shared/components/MyBanner";
import { MyContainer } from "../../shared/components/MyContainer";
import { SaveOutlined } from "@ant-design/icons";
import MyUpload from "shared/components/MyUpload";
import { Constants } from "core/utils/utils";
import ColorPicker from "antd/es/color-picker";
import {
useGetOrganizationSettingsQuery,
useIsSubdomainAvailableMutation,
useUpdateOrganizationSettingsMutation,
useUpdateSubdomainMutation,
} from "core/services/organization";
import MyErrorResult from "shared/components/MyResult";
import { useForm } from "antd/es/form/Form";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { AggregationColor } from "antd/es/color-picker/color";
import { useDispatch } from "react-redux";
import { setPrimaryColor } from "core/reducers/appSlice";
import { useDispatch, useSelector } from "react-redux";
import {
bannerUrl,
logoUrl,
setBannerUrl,
setLogoUrl,
setPrimaryColor,
} from "core/reducers/appSlice";
import MyMiddleCard from "shared/components/MyMiddleCard";
import { OrganizationSettings } from "core/types/organization";
type FieldType = {
type GeneralFieldType = {
primaryColor: string | AggregationColor;
companyName: string;
subdomain: string;
};
export default function Settings() {
@ -31,7 +39,27 @@ export default function Settings() {
}
);
const [form] = useForm<FieldType>();
if (error) return <MyErrorResult />;
return (
<>
<MyBanner title="Settings" subtitle="MANAGE" headerBar={<HeaderBar />} />
<GeneralCard data={data} isLoading={isLoading} />
<MediaCard data={data} isLoading={isLoading} />
<SubdomainCard data={data} isLoading={isLoading} />
</>
);
}
function GeneralCard({
data,
isLoading,
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
const [form] = useForm<GeneralFieldType>();
const dispatch = useDispatch();
const debounceRef = useRef<null | NodeJS.Timeout>(null);
const currentPrimaryColor = useRef<string>();
@ -39,7 +67,7 @@ export default function Settings() {
const [updateOrganizationSettings, { isLoading: isUpdateSettingsLoading }] =
useUpdateOrganizationSettingsMutation();
const handleSave = (values: FieldType) => {
const handleSave = (values: GeneralFieldType) => {
const hexColor =
typeof values.primaryColor === "string"
? values.primaryColor
@ -62,7 +90,6 @@ export default function Settings() {
form.setFieldsValue({
primaryColor: data.PrimaryColor,
companyName: data.CompanyName,
subdomain: data.Subdomain,
});
currentPrimaryColor.current = data.PrimaryColor;
@ -79,22 +106,16 @@ export default function Settings() {
};
}, []);
if (error) return <MyErrorResult />;
return (
<>
<MyBanner title="Settings" subtitle="MANAGE" headerBar={<HeaderBar />} />
<MyContainer>
<Form form={form} onFinish={handleSave} layout="vertical">
<Card
loading={isLoading}
<MyMiddleCard
styles={{
body: {
padding: 16,
},
}}
title="Branding"
title="General"
loading={isLoading}
extra={
<Button
icon={<SaveOutlined />}
@ -107,7 +128,10 @@ export default function Settings() {
}
>
<Flex wrap gap={12}>
<Form.Item<FieldType> name="primaryColor" label="Primary color">
<Form.Item<GeneralFieldType>
name="primaryColor"
label="Primary color"
>
<ColorPicker
size="small"
showText
@ -122,23 +146,34 @@ export default function Settings() {
}}
/>
</Form.Item>
<Form.Item<FieldType> name="companyName" label="Company name">
<Form.Item<GeneralFieldType> name="companyName" label="Company name">
<Input defaultValue="Jannex" />
</Form.Item>
<Form.Item<FieldType> name="subdomain" label="Subdomain">
<Input
addonBefore="https://"
addonAfter=". jannex.de"
defaultValue="mysite"
/>
</Form.Item>
</Flex>
</MyMiddleCard>
</Form>
);
}
function MediaCard({
data,
isLoading,
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
const dispatch = useDispatch();
const appLogoUrl = useSelector(logoUrl);
const appBannerUrl = useSelector(bannerUrl);
return (
<Form layout="vertical">
<MyMiddleCard title="Media" loading={isLoading}>
<Form.Item label="Logo">
<MyUpload
action={`/changeCompanyLogo`}
action="/organization/file/logo"
onChange={(info) => {
if (info.file.status === "done") {
//onThumbnailChanged?.();
console.log("done");
if (info.file.status === "done" && info.file.response.Data) {
dispatch(setLogoUrl(info.file.response.Data));
}
}}
imgCropProps={{
@ -147,7 +182,7 @@ export default function Settings() {
}}
>
<img
src={`${Constants.STATIC_CONTENT_ADDRESS}/demo/lesson_thumbnail.webp`}
src={`${Constants.STATIC_CONTENT_ADDRESS}${appLogoUrl}`}
alt="Company Logo"
style={{
width: 128,
@ -159,15 +194,13 @@ export default function Settings() {
/>
</MyUpload>
</Form.Item>
</Flex>
<Form.Item label="Thumbnail">
<Form.Item label="Banner">
<MyUpload
action={`/changeCompanyLogo`}
action="/organization/file/banner"
onChange={(info) => {
if (info.file.status === "done") {
//onThumbnailChanged?.();
console.log("done");
if (info.file.status === "done" && info.file.response.Data) {
dispatch(setBannerUrl(info.file.response.Data));
}
}}
imgCropProps={{
@ -176,8 +209,8 @@ export default function Settings() {
}}
>
<img
src={`${Constants.STATIC_CONTENT_ADDRESS}/demo/lesson_thumbnail.webp`}
alt="Thumbnail"
src={`${Constants.STATIC_CONTENT_ADDRESS}${appBannerUrl}`}
alt="Banner"
style={{
width: "100%",
height: 228,
@ -189,9 +222,153 @@ export default function Settings() {
/>
</MyUpload>
</Form.Item>
</Card>
</MyMiddleCard>
</Form>
);
}
const subdomainPattern = /^[a-zA-Z0-9-]+$/;
function SubdomainCard({
data,
isLoading,
}: { data?: OrganizationSettings; isLoading?: boolean } = {}) {
const [form] = useForm();
const [isModalOpen, setIsModalOpen] = useState(false);
const [reqIsSubdomainAvailable] = useIsSubdomainAvailableMutation();
const [reqUpdateSubdomain] = useUpdateSubdomainMutation();
const validateSubdomain = async (rule: any, value: string) => {
if (value) {
if (
value.length < Constants.GLOBALS.MIN_SUBDOMAIN_LENGTH ||
value.length > Constants.GLOBALS.MAX_SUBDOMAIN_LENGTH ||
!subdomainPattern.test(value)
) {
return Promise.reject();
}
// Check if subdomain is current subdomain
if (value === window.location.hostname.split(".")[0]) {
return Promise.resolve();
}
try {
const { data } = await reqIsSubdomainAvailable(value);
if (!data.Available) {
return Promise.reject("This subdomain is already taken!");
}
return Promise.resolve();
} catch (error) {
return Promise.reject("This subdomain is already taken!");
}
}
return Promise.resolve();
};
useEffect(() => {
if (data) {
form.setFieldsValue({
subdomain: data.Subdomain,
});
}
}, [data]);
return (
<>
<Modal
title="Change Subdomain"
open={isModalOpen}
centered
onCancel={() => setIsModalOpen(false)}
okText="Change"
onOk={() => {
try {
reqUpdateSubdomain(form.getFieldValue("subdomain"));
window.location.href = `https://${form.getFieldValue(
"subdomain"
)}.${window.location.hostname.split(".").slice(1).join(".")}`;
} catch (error) {
console.error(error);
}
}}
>
<p>
Changing your subdomain will make your organization available at the
new subdomain. Please note that you will be logged out and redirected
to the new subdomain. Also the old subdomain will be available for
registration by other users.
</p>
</Modal>
<Form
form={form}
layout="vertical"
requiredMark={false}
onFinish={(values) => {
console.log(values);
}}
>
<MyMiddleCard
title="Subdomain"
loading={isLoading}
extra={
<Button
icon={<SaveOutlined />}
type="text"
shape="circle"
size="large"
onClick={() => {
form
.validateFields()
.then(() => setIsModalOpen(true))
.catch(() => {
console.error("Validation failed!");
});
}}
/>
}
>
<Flex>
<Form.Item
name="subdomain"
label="Subdomain"
hasFeedback
validateDebounce={300}
rules={[
{
required: true,
message: "Please input your subdomain!",
},
{
pattern: subdomainPattern,
message: "Please enter a valid subdomain!",
},
{
min: Constants.GLOBALS.MIN_SUBDOMAIN_LENGTH,
message: `Subdomain must be at least ${Constants.GLOBALS.MIN_SUBDOMAIN_LENGTH} characters!`,
},
{
max: Constants.GLOBALS.MAX_SUBDOMAIN_LENGTH,
message: "Subdomain is too long!",
},
{
required: true,
validator: validateSubdomain,
},
]}
>
<Input addonBefore="https://" addonAfter=". xx.de" />
</Form.Item>
</Flex>
</MyMiddleCard>
</Form>
</MyContainer>
</>
);
}

View File

@ -1,5 +1,7 @@
import { Constants } from "core/utils/utils";
import styles from "./styles.module.css";
import { useSelector } from "react-redux";
import { bannerUrl } from "core/reducers/appSlice";
export default function MyBanner({
title,
@ -10,6 +12,8 @@ export default function MyBanner({
subtitle?: string;
headerBar?: React.ReactNode;
}) {
const appBannerUrl = useSelector(bannerUrl);
return (
<div
style={{
@ -17,7 +21,9 @@ export default function MyBanner({
}}
>
<img
src={`${Constants.STATIC_CONTENT_ADDRESS}/demo/organization_banner.jpeg`}
src={`${Constants.STATIC_CONTENT_ADDRESS}${
appBannerUrl || "/demo/organization_banner.jpeg"
}`}
alt="banner"
style={{
height: 228,

View File

@ -1,8 +1,9 @@
import ImgCrop, { ImgCropProps } from 'antd-img-crop';
import Upload from 'antd/es/upload/Upload';
import { getApiHeader } from 'core/helper/api';
import { Constants } from 'core/utils/utils';
import { Fragment } from 'react';
import ImgCrop, { ImgCropProps } from "antd-img-crop";
import Upload from "antd/es/upload/Upload";
import { getApiHeader } from "core/helper/api";
import { Constants } from "core/utils/utils";
import { Fragment, useState } from "react";
import MySpin from "../MySpin";
export default function MyUpload({
children,
@ -13,7 +14,7 @@ export default function MyUpload({
headers = getApiHeader(),
action,
onChange,
fileType = 'image',
fileType = "image",
}: {
children: React.ReactNode;
imgCropProps?: ImgCropProps;
@ -23,23 +24,27 @@ export default function MyUpload({
headers?: any;
action?: string;
onChange?: (info: any) => void;
fileType?: 'image' | 'video';
fileType?: "image" | "video";
}) {
const [uploading, setUploading] = useState(false);
const beforeUpload = (file: File) => {
if (!accept.includes(file.type)) {
console.error('File typ not allowed!');
console.error("File typ not allowed!");
return false;
}
if (file.size > Constants.MAX_IMAGE_SIZE) {
console.error('Image is to large!');
console.error("Image is to large!");
return false;
}
return true;
};
const acceptFileTypes = Array.isArray(accept) ? accept.join(',') : (accept as string);
const acceptFileTypes = Array.isArray(accept)
? accept.join(",")
: (accept as string);
const MyUpload = () => (
<Upload
@ -48,14 +53,26 @@ export default function MyUpload({
showUploadList={showUploadList}
headers={headers}
action={`${Constants.API_ADDRESS}${action}`}
onChange={onChange}
onChange={(info) => {
if (onChange) {
console.log("call");
onChange(info);
}
if (info.file.status === "uploading") {
setUploading(true);
} else if (info.file.status === "done") {
console.log("done2");
setUploading(false);
}
}}
beforeUpload={beforeUpload}
>
{children}
</Upload>
);
if (fileType === 'video') {
if (fileType === "video") {
return <MyUpload />;
}