Dev/v1.0 (#1)
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local> Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local> Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -19,27 +19,29 @@ import ManageBranches from './pages/ManageBranchesScreen/ManageBranchesScreen';
|
||||
import ManageGroups from './pages/ManageGroupsScreen/ManageGroupsScreen';
|
||||
import ManageStations from './pages/ManageStationScreen/ManageStationScreen';
|
||||
import ManageDisplays from './pages/ManageDisplays/ManageDisplays';
|
||||
import ManageAdsScreen from './pages/ManageAdsScreen/ManageAdsScreen';
|
||||
import ManageUsers from './pages/UserManagingScreen/UserManagingScreen';
|
||||
import ViewQueues from './pages/ViewBranchQueues/ViewBranchQueues';
|
||||
import { ROLES } from './constants.js';
|
||||
import { clearSession, getToken, getUserData } from './utils/session.js';
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState();
|
||||
/*
|
||||
Kada se logiramo, ako vec postoji token u localStorage, provjerimo da li je validan (nije istekao)
|
||||
Ako je validan, ulogujemo usera, ako nije ocistimo storage od starih podataka
|
||||
Kada se logiramo, ako vec postoji token u cookie-u, provjerimo da li je validan (nije istekao)
|
||||
Ako je validan, ulogujemo usera, ako nije ocistimo session
|
||||
*/
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
const url = `${ SERVER_URL }/api/v1/auth`;
|
||||
const url = `${SERVER_URL}/api/v1/auth`;
|
||||
fetchData(url, 'GET')
|
||||
.then(({ data, success }) => {
|
||||
.then(({ success }) => {
|
||||
if (success) {
|
||||
setUser(JSON.parse(localStorage.getItem('userData')));
|
||||
setUser(getUserData());
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userData');
|
||||
clearSession();
|
||||
setUser(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -47,152 +49,162 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserContext.Provider value={ { user, setUser } }>
|
||||
<UserContext.Provider value={{ user, setUser }}>
|
||||
<Header />
|
||||
<Routes>
|
||||
<Route exact path="/:tenantCode/manage/displays" element={
|
||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<ManageDisplays />
|
||||
</AuthGuard> } />
|
||||
</AuthGuard>} />
|
||||
<Route exact path="/:tenantCode/manage/ads" element={
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<ManageAdsScreen />
|
||||
</AuthGuard>} />
|
||||
<Route exact path="/:tenantCode/manage/stations" element={
|
||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<ManageStations />
|
||||
</AuthGuard> } />
|
||||
</AuthGuard>} />
|
||||
<Route exact path="/:tenantCode/manage/groups" element={
|
||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<ManageGroups />
|
||||
</AuthGuard> } />
|
||||
</AuthGuard>} />
|
||||
|
||||
<Route exact path="/:tenantCode/manage/branches" element={
|
||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<ManageBranches />
|
||||
</AuthGuard> } />
|
||||
</AuthGuard>} />
|
||||
|
||||
<Route exact path="/:tenantCode/companydetails"
|
||||
element={
|
||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
||||
<CompanyInfoUpdate />
|
||||
</AuthGuard>
|
||||
}
|
||||
element={
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<CompanyInfoUpdate />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route exact path="/:tenantCode/manage/services" element={
|
||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<ManageServices />
|
||||
</AuthGuard> }
|
||||
</AuthGuard>}
|
||||
/>
|
||||
<Route exact path="/:tenantCode/manage/users" element={
|
||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<ManageUsers />
|
||||
</AuthGuard>
|
||||
} />
|
||||
<Route exact path="/login" element={ <LoginScreen /> } />
|
||||
<Route exact path="/login" element={<LoginScreen />} />
|
||||
<Route exact path="/:tenantCode/manage/admins" element={
|
||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN] }>
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN]}>
|
||||
<ManageAdmins />
|
||||
</AuthGuard> } />
|
||||
</AuthGuard>} />
|
||||
<Route exact path="/profile" element={
|
||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<AdminProfile />
|
||||
</AuthGuard> } />
|
||||
<Route exact path="/" element={ <LoginScreen /> } />
|
||||
</AuthGuard>} />
|
||||
<Route exact path="/" element={<LoginScreen />} />
|
||||
<Route exact path="/loginauth"
|
||||
element={
|
||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
||||
<LoginAuth />
|
||||
</AuthGuard>
|
||||
}
|
||||
element={
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<LoginAuth />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route exact path="/:tenantCode/queues" element={
|
||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<ViewQueues />
|
||||
</AuthGuard> } />
|
||||
</AuthGuard>} />
|
||||
|
||||
<Route exact path="/:tenantCode/home" element={
|
||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<HomePage></HomePage>
|
||||
<CanAccess roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
||||
<CanAccess roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<>
|
||||
{ user && (
|
||||
{user && (
|
||||
<>
|
||||
<HomePageCard
|
||||
title="Manage displays"
|
||||
backgroundColor="var(--dark-blue)"
|
||||
buttonColor="var(--light-blue)"
|
||||
url={ `/${ user.tenantCode }/manage/displays` }
|
||||
url={`/${user.tenantCode}/manage/displays`}
|
||||
/>
|
||||
<HomePageCard
|
||||
title="Manage advertisements"
|
||||
backgroundColor="var(--light-blue)"
|
||||
buttonColor="var(--dark-blue)"
|
||||
url={`/${user.tenantCode}/manage/ads`}
|
||||
/>
|
||||
<HomePageCard
|
||||
title="Manage groups"
|
||||
backgroundColor="var(--light-blue)"
|
||||
buttonColor="var(--dark-blue)"
|
||||
url={ `/${ user.tenantCode }/manage/groups` }
|
||||
url={`/${user.tenantCode}/manage/groups`}
|
||||
/>
|
||||
<HomePageCard
|
||||
title="Manage branches"
|
||||
backgroundColor="var(--dark-blue)"
|
||||
buttonColor="var(--light-blue)"
|
||||
url={ `/${ user.tenantCode }/manage/branches` }
|
||||
url={`/${user.tenantCode}/manage/branches`}
|
||||
/>
|
||||
<HomePageCard
|
||||
title="Manage services"
|
||||
backgroundColor="var(--light-blue)"
|
||||
buttonColor="var(--dark-blue)"
|
||||
url={ `/${ user.tenantCode }/manage/services` }
|
||||
url={`/${user.tenantCode}/manage/services`}
|
||||
/>
|
||||
<HomePageCard
|
||||
title="Manage teller stations"
|
||||
backgroundColor="var(--light-blue)"
|
||||
buttonColor="var(--dark-blue)"
|
||||
url={ `/${ user.tenantCode }/manage/stations` }
|
||||
url={`/${user.tenantCode}/manage/stations`}
|
||||
/>
|
||||
<HomePageCard
|
||||
title="Manage company details"
|
||||
backgroundColor="var(--dark-blue)"
|
||||
buttonColor="var(--light-blue)"
|
||||
url={ `/${ user.tenantCode }/companydetails` }
|
||||
url={`/${user.tenantCode}/companydetails`}
|
||||
/>
|
||||
<HomePageCard
|
||||
title="View queues"
|
||||
backgroundColor="var(--light-blue)"
|
||||
buttonColor="var(--dark-blue)"
|
||||
url={ `/${ user.tenantCode }/queues` }
|
||||
url={`/${user.tenantCode}/queues`}
|
||||
/>
|
||||
</>
|
||||
) }
|
||||
)}
|
||||
</>
|
||||
</CanAccess>
|
||||
|
||||
<CanAccess roles={ [ROLES.ROLE_SUPER_ADMIN] }>
|
||||
<CanAccess roles={[ROLES.ROLE_SUPER_ADMIN]}>
|
||||
<>
|
||||
{ user && (
|
||||
{user && (
|
||||
<>
|
||||
<HomePageCard title="Manage administrators"
|
||||
backgroundColor="var(--dark-blue)"
|
||||
buttonColor="var(--light-blue)"
|
||||
url={ `/${ user.tenantCode }/manage/admins` }></HomePageCard>
|
||||
backgroundColor="var(--dark-blue)"
|
||||
buttonColor="var(--light-blue)"
|
||||
url={`/${user.tenantCode}/manage/admins`}></HomePageCard>
|
||||
<HomePageCard
|
||||
title="Manage users"
|
||||
title="Manage Teller"
|
||||
backgroundColor="var(--light-blue)"
|
||||
buttonColor="var(--dark-blue)"
|
||||
url={ `/${ user.tenantCode }/manage/users` }
|
||||
url={`/${user.tenantCode}/manage/users`}
|
||||
/>
|
||||
</>
|
||||
) }
|
||||
)}
|
||||
</>
|
||||
</CanAccess>
|
||||
|
||||
<CanAccess roles={ [ROLES.ROLE_BRANCH_ADMIN] }>
|
||||
{ user && (
|
||||
<CanAccess roles={[ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
{user && (
|
||||
<HomePageCard
|
||||
title="Manage users"
|
||||
backgroundColor="var(--dark-blue)"
|
||||
buttonColor="var(--light-blue)"
|
||||
url={ `/${ user.tenantCode }/manage/users` }
|
||||
url={`/${user.tenantCode}/manage/users`}
|
||||
/>
|
||||
) }
|
||||
)}
|
||||
</CanAccess>
|
||||
</AuthGuard>
|
||||
} />
|
||||
<Route path="*" element={ <NotFound /> } />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</UserContext.Provider>
|
||||
</>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getUserData } from '../../utils/session.js';
|
||||
|
||||
export default function AuthGuard({ children, roles }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const storedUserData = localStorage.getItem('userData');
|
||||
const user = storedUserData ? JSON.parse(storedUserData) : null;
|
||||
const user = getUserData();
|
||||
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
@@ -23,7 +23,7 @@ export default function AuthGuard({ children, roles }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{ children }
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -2,20 +2,19 @@ import { useContext } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { lastPathPart } from '../../utils/StringUtils.js';
|
||||
import profileImage from '../../../assets/profile-user.png'
|
||||
|
||||
import './Header.css';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { clearSession } from '../../utils/session.js';
|
||||
|
||||
export default function Header() {
|
||||
const navigate = useNavigate();
|
||||
const { user, setUser } = useContext(UserContext);
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('userData');
|
||||
localStorage.removeItem('token');
|
||||
clearSession();
|
||||
setUser(null);
|
||||
navigate('/');
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
const path = window.location.pathname;
|
||||
@@ -35,28 +34,31 @@ export default function Header() {
|
||||
return (
|
||||
<>
|
||||
<header className="main-header">
|
||||
<h2 className="header-logo" onClick={ goHome }>BBQMS</h2>
|
||||
<h2 className="header-logo" onClick={goHome}>
|
||||
MyKOPKB QMS-Admin
|
||||
</h2>
|
||||
|
||||
<div className="header-logout">
|
||||
{ !!user && (
|
||||
<button className="header-logout-btn" onClick={ handleLogout }>
|
||||
{!!user && (
|
||||
<button
|
||||
className="header-logout-btn"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
) }
|
||||
</div>
|
||||
|
||||
<div className="header-profile" onClick={ () => navigate('/profile') }>
|
||||
<img src={ profileImage } className="header-profile-png" alt="Profile image" />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{ showBackButton && (
|
||||
<Button variant="secondary"
|
||||
className="mt-2 px-4"
|
||||
onClick={ goHome }>
|
||||
|
||||
{showBackButton && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-2 px-4"
|
||||
onClick={goHome}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
) }
|
||||
)}
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { SERVER_URL } from "../../constants.js";
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getUserData, setSession } from '../../utils/session.js';
|
||||
|
||||
function doSubmit(submittedValues) {
|
||||
console.log(`Submitted: ${submittedValues.join("")}`);
|
||||
@@ -126,10 +127,9 @@ export default function LoginAuth() {
|
||||
dispatch({ type: "VERIFY" });
|
||||
|
||||
try {
|
||||
const storedUserData = localStorage.getItem('userData');
|
||||
const userData = storedUserData ? JSON.parse(storedUserData) : null;
|
||||
const userData = getUserData();
|
||||
if (!userData) {
|
||||
throw new Error("Email not found in localStorage");
|
||||
throw new Error("Email not found in session");
|
||||
}
|
||||
|
||||
const url = `${ SERVER_URL }/api/v1/auth/tfa`;
|
||||
@@ -139,7 +139,11 @@ export default function LoginAuth() {
|
||||
});
|
||||
|
||||
if (success) {
|
||||
localStorage.setItem('token', data.token);
|
||||
setSession({
|
||||
token: data.token,
|
||||
userData: data.userData,
|
||||
isTfa: true,
|
||||
});
|
||||
setUser(data.userData);
|
||||
navigate(`/${ data.userData.tenantCode }/home`);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@ import "./LoginForm.css";
|
||||
import LoginAuth from "../LoginAuth/LoginAuth";
|
||||
import { Route, Routes, useNavigate, Link } from "react-router-dom";
|
||||
import { SERVER_URL } from "../../constants.js";
|
||||
import { setSession } from "../../utils/session.js";
|
||||
|
||||
const LoginForm = () => {
|
||||
const [username, setUsername] = useState("");
|
||||
@@ -28,7 +29,11 @@ const LoginForm = () => {
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
localStorage.setItem('userData', JSON.stringify(data));
|
||||
setSession({
|
||||
userData: data.userData ?? data,
|
||||
token: data.token,
|
||||
isTfa: !data.token,
|
||||
});
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
@@ -67,9 +72,12 @@ const LoginForm = () => {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
localStorage.setItem('userData', JSON.stringify(data));
|
||||
|
||||
if (response.ok) {
|
||||
setSession({
|
||||
userData: data.userData ?? data,
|
||||
token: data.token,
|
||||
isTfa: !data.token,
|
||||
});
|
||||
setIsSubmitted(true);
|
||||
navigate('/');
|
||||
} else if (response.status === 403) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export const SERVER_URL = 'http://localhost:8080';
|
||||
|
||||
export const CUSTOMER_APP_URL =
|
||||
import.meta.env.VITE_CUSTOMER_APP_URL ?? 'http://localhost:3000';
|
||||
|
||||
export const ROLES = {
|
||||
ROLE_SUPER_ADMIN : "ROLE_SUPER_ADMIN",
|
||||
ROLE_BRANCH_ADMIN : "ROLE_BRANCH_ADMIN"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/*
|
||||
Koristiti ovu funkciju za fetchanje u buducnosti kad god je to moguce.
|
||||
*/
|
||||
import { getToken, setToken } from '../utils/session.js';
|
||||
|
||||
export async function fetchData(url, method, body) {
|
||||
const headers = new Headers();
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.append('Authorization', `Bearer ${ token }`);
|
||||
}
|
||||
@@ -27,9 +29,9 @@ export async function fetchData(url, method, body) {
|
||||
//na svaki ispravan rezultat treba da dobijemo novi token da refreshamo stari
|
||||
const newToken = res.headers.get('Auth-Token');
|
||||
if (newToken) {
|
||||
localStorage.setItem('token', newToken);
|
||||
setToken(newToken);
|
||||
}
|
||||
}
|
||||
|
||||
return { data: data, success: res.ok };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getToken, setToken } from '../utils/session.js';
|
||||
|
||||
/**
|
||||
* Multipart upload helper. Do not set Content-Type manually — the browser
|
||||
* must add the multipart boundary.
|
||||
*/
|
||||
export async function uploadFormData(url, formData, method = 'POST') {
|
||||
const headers = new Headers();
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.append('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
const newToken = res.headers.get('Auth-Token');
|
||||
if (newToken) {
|
||||
setToken(newToken);
|
||||
}
|
||||
}
|
||||
|
||||
return { data, success: res.ok };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { getToken } from '../../utils/session.js';
|
||||
|
||||
const styles = {
|
||||
primaryButton: {
|
||||
@@ -30,22 +31,22 @@ const AdminManageScreen = () => {
|
||||
const [adminEmail, setAdminEmail] = useState('');
|
||||
const [adminPassword, setAdminPassword] = useState('');
|
||||
const [selectedAdminIndex, setSelectedAdminIndex] = useState(null);
|
||||
const [token, setToken] = useState('');
|
||||
const [emailError, setEmailError] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const storedToken = localStorage.getItem('token');
|
||||
if (storedToken) {
|
||||
setToken(storedToken);
|
||||
if (getToken()) {
|
||||
fetchAdmins();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchAdmins();
|
||||
}
|
||||
}, [token]);
|
||||
const authHeaders = () => {
|
||||
const token = getToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const fetchAdmins = async () => {
|
||||
try {
|
||||
@@ -54,10 +55,7 @@ const AdminManageScreen = () => {
|
||||
});
|
||||
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token
|
||||
},
|
||||
headers: authHeaders(),
|
||||
body: requestBody
|
||||
});
|
||||
|
||||
@@ -87,10 +85,7 @@ const AdminManageScreen = () => {
|
||||
try {
|
||||
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token
|
||||
},
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
@@ -122,10 +117,7 @@ const AdminManageScreen = () => {
|
||||
};
|
||||
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${admins[selectedAdminIndex].id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token
|
||||
},
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(updatedAdmin),
|
||||
});
|
||||
if (response.ok) {
|
||||
@@ -146,10 +138,7 @@ const AdminManageScreen = () => {
|
||||
try {
|
||||
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token
|
||||
}
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (response.ok) {
|
||||
const updatedAdmins = admins.filter(admin => admin.id !== userId);
|
||||
|
||||
@@ -1,59 +1,57 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { SERVER_URL } from '../../constants';
|
||||
import { getIsTfa, getUserData, setIsTfa } from '../../utils/session.js';
|
||||
import "./AdminProfile.css"
|
||||
|
||||
export default function AdminProfile(){
|
||||
export default function AdminProfile() {
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
const [isQRCodeEnabled, setIsQRCodeEnabled] = useState(false);
|
||||
const [qrCodeSrc, setQrCodeSrc] = useState('');
|
||||
const [userData, setUserData] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const storedUserData = localStorage.getItem('userData');
|
||||
setUserData(JSON.parse(storedUserData));
|
||||
|
||||
const storedIsTfa = localStorage.getItem('isTfa');
|
||||
let isTfa = JSON.parse(storedIsTfa);
|
||||
setUserData(getUserData());
|
||||
|
||||
const isTfa = getIsTfa();
|
||||
setIsChecked(isTfa);
|
||||
setIsQRCodeEnabled(isTfa);
|
||||
}, []);
|
||||
|
||||
const handleSaveChanges = async () =>{
|
||||
const url = `${ SERVER_URL }/api/v1/auth/tfa`;
|
||||
const handleSaveChanges = async () => {
|
||||
const url = `${SERVER_URL}/api/v1/auth/tfa`;
|
||||
const { data, success } = await fetchData(url, 'PUT', {
|
||||
isTfa: isChecked
|
||||
});
|
||||
localStorage.setItem('isTfa', isChecked);
|
||||
setIsTfa(isChecked);
|
||||
setIsQRCodeEnabled(isChecked);
|
||||
if(!isChecked){
|
||||
if (!isChecked) {
|
||||
setQrCodeSrc('');
|
||||
}
|
||||
if(success){
|
||||
if (success) {
|
||||
let message = 'Success: Your changes have been successfully submitted.';
|
||||
if(isChecked){
|
||||
if (isChecked) {
|
||||
message = message + '\nPlease scan QR code.';
|
||||
}
|
||||
alert(message);
|
||||
}else{
|
||||
} else {
|
||||
alert('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckBoxChange = () =>{
|
||||
const handleCheckBoxChange = () => {
|
||||
setIsChecked(!isChecked);
|
||||
}
|
||||
|
||||
const handleGenerateQRCode = () =>{
|
||||
if(isQRCodeEnabled){
|
||||
const url = `${ SERVER_URL }/api/v1/auth/tfa?email=${userData.email}`;
|
||||
const handleGenerateQRCode = () => {
|
||||
if (isQRCodeEnabled) {
|
||||
const url = `${SERVER_URL}/api/v1/auth/tfa?email=${userData.email}`;
|
||||
fetchData(url, 'GET')
|
||||
.then(({ data, success }) => {
|
||||
if (success) {
|
||||
setQrCodeSrc(data.qrCode);
|
||||
}
|
||||
});
|
||||
.then(({ data, success }) => {
|
||||
if (success) {
|
||||
setQrCodeSrc(data.qrCode);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,17 +59,17 @@ export default function AdminProfile(){
|
||||
<div id="account-settings">
|
||||
<h1>Account settings</h1>
|
||||
<div id="check-box">
|
||||
<form>
|
||||
{/* <form>
|
||||
<label htmlFor="2fa">Use two-factor authentication:</label>
|
||||
<input type="checkbox" id="2fa" name="2fa" checked={isChecked} onChange={handleCheckBoxChange}></input>
|
||||
</form>
|
||||
</form> */}
|
||||
</div>
|
||||
<div id="QR-code">
|
||||
<input type="submit" value="Generate QR code" disabled={!isQRCodeEnabled} onClick={handleGenerateQRCode}/>
|
||||
{/* <div id="QR-code">
|
||||
<input type="submit" value="Generate QR code" disabled={!isQRCodeEnabled} onClick={handleGenerateQRCode} />
|
||||
<img src={qrCodeSrc}></img>
|
||||
</div>
|
||||
</div> */}
|
||||
<div>
|
||||
<input type="submit" value="Save changes" onClick={handleSaveChanges}/>
|
||||
<input type="submit" value="Save changes" onClick={handleSaveChanges} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import validator from 'validator';
|
||||
import './LoginScreen.css';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
import { ROLES, SERVER_URL } from '../../constants.js';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { clearSession, setSession } from '../../utils/session.js';
|
||||
|
||||
const ADMIN_ROLES = [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN];
|
||||
|
||||
function hasAdminAccess(userData) {
|
||||
const roles = Array.isArray(userData?.roles) ? userData.roles : [];
|
||||
return roles.some((role) => ADMIN_ROLES.includes(role));
|
||||
}
|
||||
|
||||
export default function LoginScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { setUser } = useContext(UserContext);
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -38,26 +47,42 @@ export default function LoginScreen() {
|
||||
password: password
|
||||
});
|
||||
|
||||
if (success) {
|
||||
if (data.userData == undefined) {
|
||||
localStorage.setItem('userData', JSON.stringify(data));
|
||||
} else {
|
||||
localStorage.setItem('userData', JSON.stringify(data.userData));
|
||||
localStorage.setItem('token', data.token);
|
||||
setUser(data.userData);
|
||||
}
|
||||
setIsSubmitted(true);
|
||||
|
||||
if (data.token) {
|
||||
localStorage.setItem('isTfa', false);
|
||||
navigate(`/${data.userData.tenantCode}/home`);
|
||||
} else {
|
||||
localStorage.setItem('isTfa', true);
|
||||
navigate('/loginauth');
|
||||
}
|
||||
} else {
|
||||
if (!success || !data) {
|
||||
setError('Your credentials are incorrect.');
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = data.userData ?? data;
|
||||
const token = data.token;
|
||||
|
||||
if (!hasAdminAccess(userData)) {
|
||||
clearSession();
|
||||
setUser(null);
|
||||
setError(
|
||||
'This account is a regular user (ROLE_USER) and cannot access the admin app. Create an admin via Manage administrators.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
setSession({ userData, isTfa: true });
|
||||
setUser(userData);
|
||||
navigate('/loginauth', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userData?.tenantCode) {
|
||||
setError('Login succeeded but tenant information is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSession({
|
||||
userData,
|
||||
token,
|
||||
isTfa: false,
|
||||
});
|
||||
setUser(userData);
|
||||
navigate(`/${userData.tenantCode}/home`, { replace: true });
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
setError('An error occurred. Please try again.');
|
||||
@@ -73,11 +98,6 @@ export default function LoginScreen() {
|
||||
setPassword(event.target.value);
|
||||
setError('');
|
||||
};
|
||||
/*
|
||||
if (isSubmitted) {
|
||||
//navigate('/loginAuth');
|
||||
navigate('/companydetails');
|
||||
}*/
|
||||
|
||||
return (
|
||||
<div id="login-form">
|
||||
@@ -92,9 +112,7 @@ export default function LoginScreen() {
|
||||
value={username}
|
||||
onChange={handleUsernameChange}
|
||||
/>
|
||||
{error && (error.includes('Username') || error.includes('Invalid')) &&
|
||||
<p className="error">{error}</p>}
|
||||
{error && (error.includes('credentials')) && <p className="error">{error}</p>}
|
||||
{error && !error.includes('Password') && <p className="error">{error}</p>}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password:</label>
|
||||
@@ -116,4 +134,4 @@ export default function LoginScreen() {
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
.ad-tv-preview {
|
||||
text-align: left;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #334155;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.ad-tv-preview__chrome {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.ad-tv-preview__label {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.ad-tv-preview__ratio {
|
||||
font-size: 0.8rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.ad-tv-preview__stage {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ad-tv-preview__media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.ad-tv-preview__empty {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ad-tv-preview__caption {
|
||||
margin: 0.65rem 0 0;
|
||||
text-align: center;
|
||||
font-size: 0.95rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button, Form, Modal, Table } from 'react-bootstrap';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { uploadFormData } from '../../fetching/uploadFormData.js';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
import './ManageAdsScreen.css';
|
||||
|
||||
const styles = {
|
||||
primaryButton: {
|
||||
backgroundColor: '#548CA8',
|
||||
borderColor: '#548CA8',
|
||||
},
|
||||
infoButton: {
|
||||
backgroundColor: '#548CA8',
|
||||
color: 'white',
|
||||
borderColor: '#548CA8',
|
||||
},
|
||||
modalHeader: {
|
||||
backgroundColor: '#334257',
|
||||
color: 'white',
|
||||
},
|
||||
};
|
||||
|
||||
const ACCEPTED_TYPES = 'image/jpeg,image/png,image/webp,image/gif,video/mp4,video/webm';
|
||||
|
||||
/** Matches branch TV ad box (see BranchDisplayPage `.branch-display__ad-stage`). */
|
||||
const AD_DISPLAY_SPEC = {
|
||||
aspectRatio: '16:10',
|
||||
recommendedSize: '1920 × 1200 px',
|
||||
minSize: '1280 × 800 px',
|
||||
};
|
||||
|
||||
function AdDisplayGuidelines() {
|
||||
return (
|
||||
<div
|
||||
className="text-start small rounded border p-3 mb-3"
|
||||
style={{ backgroundColor: '#f8fafc', borderColor: '#cbd5e1' }}
|
||||
>
|
||||
<strong>TV display size guide</strong>
|
||||
<ul className="mb-0 mt-2 ps-3">
|
||||
<li>
|
||||
Aspect ratio: <strong>{AD_DISPLAY_SPEC.aspectRatio}</strong> (width : height)
|
||||
</li>
|
||||
<li>
|
||||
Recommended resolution: <strong>{AD_DISPLAY_SPEC.recommendedSize}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Minimum for clear TV: <strong>{AD_DISPLAY_SPEC.minSize}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Use landscape media that fills the frame. Other ratios will show empty bars
|
||||
or look cropped on the branch display.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mediaUrlFromAd(ad) {
|
||||
if (!ad?.mediaUrl) return null;
|
||||
return ad.mediaUrl.startsWith('http')
|
||||
? ad.mediaUrl
|
||||
: `${SERVER_URL}${ad.mediaUrl}`;
|
||||
}
|
||||
|
||||
function isVideoFile(file) {
|
||||
return Boolean(file?.type?.startsWith('video/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mimics the branch TV advertisement panel (16:10 stage).
|
||||
*/
|
||||
function AdTvPreview({ src, isVideo, title, emptyLabel = 'Select a file to preview' }) {
|
||||
return (
|
||||
<div className="ad-tv-preview">
|
||||
<div className="ad-tv-preview__chrome">
|
||||
<span className="ad-tv-preview__label">Branch TV preview</span>
|
||||
<span className="ad-tv-preview__ratio">{AD_DISPLAY_SPEC.aspectRatio}</span>
|
||||
</div>
|
||||
<div className="ad-tv-preview__stage">
|
||||
{!src ? (
|
||||
<p className="ad-tv-preview__empty">{emptyLabel}</p>
|
||||
) : isVideo ? (
|
||||
<video
|
||||
key={src}
|
||||
className="ad-tv-preview__media"
|
||||
src={src}
|
||||
controls
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={src}
|
||||
className="ad-tv-preview__media"
|
||||
src={src}
|
||||
alt={title || 'Advertisement preview'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{title ? <p className="ad-tv-preview__caption">{title}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ManageAdsScreen() {
|
||||
const { tenantCode } = useParams();
|
||||
const [ads, setAds] = useState([]);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [selectedAd, setSelectedAd] = useState(null);
|
||||
|
||||
const [file, setFile] = useState(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [durationSeconds, setDurationSeconds] = useState(10);
|
||||
const [sortOrder, setSortOrder] = useState('');
|
||||
const [active, setActive] = useState(true);
|
||||
|
||||
const url = `${SERVER_URL}/api/v1/ads/${encodeURIComponent(tenantCode)}`;
|
||||
|
||||
const uploadObjectUrl = useMemo(() => {
|
||||
if (!file) return null;
|
||||
return URL.createObjectURL(file);
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (uploadObjectUrl) {
|
||||
URL.revokeObjectURL(uploadObjectUrl);
|
||||
}
|
||||
};
|
||||
}, [uploadObjectUrl]);
|
||||
|
||||
const loadAds = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchData(url, 'GET');
|
||||
if (!response.success) {
|
||||
setErrorMessage('Failed to load advertisements.');
|
||||
return;
|
||||
}
|
||||
setAds(Array.isArray(response.data) ? response.data : []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setErrorMessage('Failed to load advertisements.');
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAds();
|
||||
}, [loadAds]);
|
||||
|
||||
function resetUploadForm() {
|
||||
setFile(null);
|
||||
setTitle('');
|
||||
setDurationSeconds(10);
|
||||
setSortOrder('');
|
||||
setActive(true);
|
||||
}
|
||||
|
||||
function openEdit(ad) {
|
||||
setSelectedAd(ad);
|
||||
setTitle(ad.title ?? '');
|
||||
setDurationSeconds(ad.durationSeconds ?? 10);
|
||||
setSortOrder(String(ad.sortOrder ?? 0));
|
||||
setActive(Boolean(ad.active));
|
||||
setShowEdit(true);
|
||||
}
|
||||
|
||||
function openPreview(ad) {
|
||||
setSelectedAd(ad);
|
||||
setShowPreview(true);
|
||||
}
|
||||
|
||||
async function handleUpload(event) {
|
||||
event.preventDefault();
|
||||
if (!file) {
|
||||
setErrorMessage('Choose an image or video file to upload.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (title.trim()) {
|
||||
formData.append('title', title.trim());
|
||||
}
|
||||
formData.append('durationSeconds', String(durationSeconds || 10));
|
||||
formData.append('active', String(active));
|
||||
if (sortOrder !== '' && !Number.isNaN(Number(sortOrder))) {
|
||||
formData.append('sortOrder', String(Number(sortOrder)));
|
||||
}
|
||||
|
||||
const response = await uploadFormData(url, formData, 'POST');
|
||||
if (!response.success) {
|
||||
setErrorMessage(response.data?.message || 'Failed to upload advertisement.');
|
||||
return;
|
||||
}
|
||||
|
||||
setShowUpload(false);
|
||||
resetUploadForm();
|
||||
await loadAds();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setErrorMessage('Failed to upload advertisement.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit(event) {
|
||||
event.preventDefault();
|
||||
if (!selectedAd) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchData(`${url}/${selectedAd.id}`, 'PUT', {
|
||||
title: title.trim() || null,
|
||||
durationSeconds: Number(durationSeconds) || 10,
|
||||
sortOrder: sortOrder === '' ? null : Number(sortOrder),
|
||||
active,
|
||||
});
|
||||
if (!response.success) {
|
||||
setErrorMessage(response.data?.message || 'Failed to update advertisement.');
|
||||
return;
|
||||
}
|
||||
setShowEdit(false);
|
||||
setSelectedAd(null);
|
||||
await loadAds();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setErrorMessage('Failed to update advertisement.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedAd) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchData(`${url}/${selectedAd.id}`, 'DELETE');
|
||||
if (!response.success) {
|
||||
setErrorMessage(response.data?.message || 'Failed to delete advertisement.');
|
||||
return;
|
||||
}
|
||||
setShowDelete(false);
|
||||
setSelectedAd(null);
|
||||
await loadAds();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setErrorMessage('Failed to delete advertisement.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h2>Manage Advertisements</h2>
|
||||
<p className="text-muted mb-2">
|
||||
Ads are shared across all branches for this company.
|
||||
</p>
|
||||
<div className="mx-auto mb-3" style={{ maxWidth: 640 }}>
|
||||
<AdDisplayGuidelines />
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={styles.primaryButton}
|
||||
className="mb-3"
|
||||
onClick={() => {
|
||||
resetUploadForm();
|
||||
setShowUpload(true);
|
||||
}}
|
||||
>
|
||||
Upload Ad
|
||||
</Button>
|
||||
|
||||
<Table striped bordered hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preview</th>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th>Order</th>
|
||||
<th>Duration (s)</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ads.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-muted">
|
||||
No advertisements yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
ads.map((ad) => (
|
||||
<tr key={ad.id}>
|
||||
<td style={{ width: 120 }}>
|
||||
{ad.mediaType === 'IMAGE' ? (
|
||||
<img
|
||||
src={mediaUrlFromAd(ad)}
|
||||
alt={ad.title || ad.fileName}
|
||||
style={{
|
||||
maxWidth: 100,
|
||||
maxHeight: 60,
|
||||
objectFit: 'cover',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => openPreview(ad)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0"
|
||||
onClick={() => openPreview(ad)}
|
||||
>
|
||||
Video
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
<td>{ad.title || ad.fileName}</td>
|
||||
<td>{ad.mediaType}</td>
|
||||
<td>{ad.sortOrder}</td>
|
||||
<td>{ad.durationSeconds}</td>
|
||||
<td>{ad.active ? 'Yes' : 'No'}</td>
|
||||
<td>
|
||||
<Button
|
||||
variant="info"
|
||||
style={styles.infoButton}
|
||||
onClick={() => openPreview(ad)}
|
||||
>
|
||||
Preview
|
||||
</Button>{' '}
|
||||
<Button
|
||||
variant="info"
|
||||
style={styles.infoButton}
|
||||
onClick={() => openEdit(ad)}
|
||||
>
|
||||
Edit
|
||||
</Button>{' '}
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
setSelectedAd(ad);
|
||||
setShowDelete(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<Modal
|
||||
show={showUpload}
|
||||
onHide={() => {
|
||||
setShowUpload(false);
|
||||
resetUploadForm();
|
||||
}}
|
||||
size="lg"
|
||||
>
|
||||
<Modal.Header closeButton style={styles.modalHeader}>
|
||||
<Modal.Title>UPLOAD ADVERTISEMENT</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Form onSubmit={handleUpload}>
|
||||
<Modal.Body>
|
||||
<AdDisplayGuidelines />
|
||||
<AdTvPreview
|
||||
src={uploadObjectUrl}
|
||||
isVideo={isVideoFile(file)}
|
||||
title={title.trim() || file?.name}
|
||||
/>
|
||||
<Form.Group className="mb-3 mt-3">
|
||||
<Form.Label>File (image or video)</Form.Label>
|
||||
<Form.Control
|
||||
type="file"
|
||||
accept={ACCEPTED_TYPES}
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
required
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
Best fit: {AD_DISPLAY_SPEC.aspectRatio},{' '}
|
||||
{AD_DISPLAY_SPEC.recommendedSize}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Title</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Duration seconds (images)</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
min={1}
|
||||
value={durationSeconds}
|
||||
onChange={(event) => setDurationSeconds(event.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Sort order</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(event) => setSortOrder(event.target.value)}
|
||||
placeholder="Auto if empty"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Check
|
||||
type="switch"
|
||||
id="upload-active"
|
||||
label="Active"
|
||||
checked={active}
|
||||
onChange={(event) => setActive(event.target.checked)}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowUpload(false);
|
||||
resetUploadForm();
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
style={styles.primaryButton}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Uploading…' : 'Upload'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal show={showEdit} onHide={() => setShowEdit(false)} size="lg">
|
||||
<Modal.Header closeButton style={styles.modalHeader}>
|
||||
<Modal.Title>EDIT ADVERTISEMENT</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Form onSubmit={handleEdit}>
|
||||
<Modal.Body>
|
||||
{selectedAd ? (
|
||||
<AdTvPreview
|
||||
src={mediaUrlFromAd(selectedAd)}
|
||||
isVideo={selectedAd.mediaType === 'VIDEO'}
|
||||
title={title.trim() || selectedAd.fileName}
|
||||
/>
|
||||
) : null}
|
||||
<Form.Group className="mb-3 mt-3">
|
||||
<Form.Label>Title</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Duration seconds (images)</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
min={1}
|
||||
value={durationSeconds}
|
||||
onChange={(event) => setDurationSeconds(event.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Sort order</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(event) => setSortOrder(event.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Check
|
||||
type="switch"
|
||||
id="edit-active"
|
||||
label="Active"
|
||||
checked={active}
|
||||
onChange={(event) => setActive(event.target.checked)}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => setShowEdit(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
style={styles.primaryButton}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
show={showPreview}
|
||||
onHide={() => {
|
||||
setShowPreview(false);
|
||||
setSelectedAd(null);
|
||||
}}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Modal.Header closeButton style={styles.modalHeader}>
|
||||
<Modal.Title>TV DISPLAY PREVIEW</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{selectedAd ? (
|
||||
<AdTvPreview
|
||||
src={mediaUrlFromAd(selectedAd)}
|
||||
isVideo={selectedAd.mediaType === 'VIDEO'}
|
||||
title={selectedAd.title || selectedAd.fileName}
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-muted small mt-3 mb-0 text-center">
|
||||
This matches the advertisement panel on the branch waiting-area TV
|
||||
({AD_DISPLAY_SPEC.aspectRatio}).
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowPreview(false);
|
||||
setSelectedAd(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
<Modal show={showDelete} onHide={() => setShowDelete(false)}>
|
||||
<Modal.Header closeButton style={styles.modalHeader}>
|
||||
<Modal.Title>CONFIRMATION</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p>
|
||||
Delete advertisement{' '}
|
||||
<strong>{selectedAd?.title || selectedAd?.fileName}</strong>?
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => setShowDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={loading}>
|
||||
{loading ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
show={errorMessage !== ''}
|
||||
onHide={() => setErrorMessage('')}
|
||||
backdrop="static"
|
||||
keyboard={false}
|
||||
>
|
||||
<Modal.Header closeButton style={{ backgroundColor: '#dc3545', color: 'white' }}>
|
||||
<Modal.Title>ERROR</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p>{errorMessage}</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => setErrorMessage('')}>
|
||||
Close
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
import {
|
||||
downloadBranchQr,
|
||||
generateBranchQrDataUrl,
|
||||
getBranchTicketUrl,
|
||||
} from '../../utils/branchQr.js';
|
||||
|
||||
const styles = {
|
||||
primaryButton: {
|
||||
@@ -30,6 +35,9 @@ const ManageBranchesScreen = () => {
|
||||
const [newStationName, setNewStationName] = useState('');
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [qrModalBranch, setQrModalBranch] = useState(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
const { tenantCode } = useParams();
|
||||
|
||||
const url = `${SERVER_URL}/api/v1/branches/${tenantCode}`;
|
||||
@@ -154,6 +162,42 @@ const ManageBranchesScreen = () => {
|
||||
setSelectedBranchIndex(index);
|
||||
};
|
||||
|
||||
const handleShowQr = async (branch) => {
|
||||
setQrModalBranch(branch);
|
||||
setQrDataUrl('');
|
||||
setQrLoading(true);
|
||||
try {
|
||||
const dataUrl = await generateBranchQrDataUrl(tenantCode, branch.id);
|
||||
setQrDataUrl(dataUrl);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
setQrModalBranch(null);
|
||||
setErrorMessage('Failed to generate branch QR code.');
|
||||
} finally {
|
||||
setQrLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadQr = async () => {
|
||||
if (!qrModalBranch) return;
|
||||
try {
|
||||
await downloadBranchQr({
|
||||
tenantCode,
|
||||
branchId: qrModalBranch.id,
|
||||
branchName: qrModalBranch.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
setErrorMessage('Failed to download branch QR code.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseQrModal = () => {
|
||||
setQrModalBranch(null);
|
||||
setQrDataUrl('');
|
||||
setQrLoading(false);
|
||||
};
|
||||
|
||||
const confirmDeleteBranch = async () => {
|
||||
const branchId = manageBranches[selectedBranchIndex].id;
|
||||
const urlToDelete = `${url}/${branchId}`;
|
||||
@@ -221,6 +265,7 @@ const ManageBranchesScreen = () => {
|
||||
<td>{branch.tellerStations ? branch.tellerStations.map(station => station.name).join(', ') : '-'}</td>
|
||||
<td>
|
||||
<Button variant="info" style={styles.infoButton} onClick={() => handleEditClick(index)}>Edit</Button>{' '}
|
||||
<Button variant="info" style={styles.infoButton} onClick={() => handleShowQr(branch)}>QR</Button>{' '}
|
||||
<Button variant="danger" onClick={() => handleDeleteBranch(index)}>Delete</Button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -270,6 +315,46 @@ const ManageBranchesScreen = () => {
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
<Modal show={qrModalBranch !== null} onHide={handleCloseQrModal}>
|
||||
<Modal.Header closeButton style={styles.modalHeader}>
|
||||
<Modal.Title>
|
||||
BRANCH QR{qrModalBranch ? ` — ${qrModalBranch.name}` : ''}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="text-center">
|
||||
{qrLoading ? (
|
||||
<p>Generating QR…</p>
|
||||
) : qrDataUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt={`QR code for ${qrModalBranch?.name}`}
|
||||
style={{ width: 280, height: 280, maxWidth: '100%' }}
|
||||
/>
|
||||
<p className="mt-3 mb-0 text-break small text-muted">
|
||||
{qrModalBranch
|
||||
? getBranchTicketUrl(tenantCode, qrModalBranch.id)
|
||||
: ''}
|
||||
</p>
|
||||
<p className="mt-2 mb-0 small">
|
||||
Print this QR and place it at the branch. Customers scan it to choose a service.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={handleCloseQrModal}>Close</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={styles.primaryButton}
|
||||
onClick={handleDownloadQr}
|
||||
disabled={!qrDataUrl || qrLoading}
|
||||
>
|
||||
Download PNG
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
<Modal show={deleteConfirmation} onHide={() => setDeleteConfirmation(false)}>
|
||||
<Modal.Header closeButton style={{ backgroundColor: '#334257', color: 'white' }}>
|
||||
<Modal.Title>CONFIRMATION</Modal.Title>
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Button, Table, Modal, Form } from 'react-bootstrap';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { getToken } from '../../utils/session.js';
|
||||
|
||||
const styles = {
|
||||
primaryButton: {
|
||||
@@ -29,34 +29,31 @@ const UserManageScreen = () => {
|
||||
const [userEmail, setUserEmail] = useState('');
|
||||
const [userPassword, setUserPassword] = useState('');
|
||||
const [selectedUserIndex, setSelectedUserIndex] = useState(null);
|
||||
const [token, setToken] = useState('');
|
||||
const [emailError, setEmailError] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const storedToken = localStorage.getItem('token');
|
||||
if (storedToken) {
|
||||
setToken(storedToken);
|
||||
if (getToken()) {
|
||||
fetchUsers();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchUsers();
|
||||
}
|
||||
}, [token]);
|
||||
const authHeaders = () => {
|
||||
const token = getToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const requestBody = JSON.stringify({
|
||||
roleName: 'ROLE_USER'
|
||||
});
|
||||
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}`, {
|
||||
const response = await fetch(`${SERVER_URL}/api/v1/admin/${tenantCode}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token
|
||||
},
|
||||
headers: authHeaders(),
|
||||
body: requestBody
|
||||
});
|
||||
|
||||
@@ -84,12 +81,9 @@ const UserManageScreen = () => {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user`, {
|
||||
const response = await fetch(`${SERVER_URL}/api/v1/admin/${tenantCode}/user`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token
|
||||
},
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
@@ -119,12 +113,9 @@ const UserManageScreen = () => {
|
||||
const updatedUser = {
|
||||
email: userEmail
|
||||
};
|
||||
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${users[selectedUserIndex].id}`, {
|
||||
const response = await fetch(`${SERVER_URL}/api/v1/admin/${tenantCode}/user/${users[selectedUserIndex].id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token
|
||||
},
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(updatedUser),
|
||||
});
|
||||
if (response.ok) {
|
||||
@@ -144,12 +135,9 @@ const UserManageScreen = () => {
|
||||
|
||||
const handleDeleteUser = async (userId) => {
|
||||
try {
|
||||
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${userId}`, {
|
||||
const response = await fetch(`${SERVER_URL}/api/v1/admin/${tenantCode}/user/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token
|
||||
}
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (response.ok) {
|
||||
const updatedUsers = users.filter(user => user.id !== userId);
|
||||
@@ -171,28 +159,32 @@ const UserManageScreen = () => {
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h2>Manage Users</h2>
|
||||
<h2>Manage Teller</h2>
|
||||
<p className="text-muted mb-3">
|
||||
These accounts have ROLE_USER and cannot log into the admin app.
|
||||
Use Manage administrators to create admin logins.
|
||||
</p>
|
||||
<Button variant="primary" style={styles.primaryButton} className="mb-3" onClick={() => { setShowModal(true); setSelectedUserIndex(null); }}>Add User</Button>
|
||||
|
||||
<Table striped bordered hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user, index) => (
|
||||
<tr key={index}>
|
||||
<td>{user.id}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
<Button variant="info" style={styles.infoButton} onClick={() => handleEditClick(index)}>Edit</Button>{' '}
|
||||
<Button variant="danger" onClick={() => handleDeleteUser(user.id)}>Delete</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.map((user, index) => (
|
||||
<tr key={index}>
|
||||
<td>{user.id}</td>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
<Button variant="info" style={styles.infoButton} onClick={() => handleEditClick(index)}>Edit</Button>{' '}
|
||||
<Button variant="danger" onClick={() => handleDeleteUser(user.id)}>Delete</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import QRCode from 'qrcode';
|
||||
import { CUSTOMER_APP_URL } from '../constants.js';
|
||||
|
||||
const DEFAULT_QR_SECRET = 'qms-branch-qr-v1';
|
||||
|
||||
function getQrSecret() {
|
||||
return import.meta.env.VITE_QR_TOKEN_SECRET || DEFAULT_QR_SECRET;
|
||||
}
|
||||
|
||||
function toBase64Url(bytes) {
|
||||
let binary = '';
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function fromBase64Url(token) {
|
||||
const padded = token.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padLength = (4 - (padded.length % 4)) % 4;
|
||||
const base64 = padded + '='.repeat(padLength);
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function xorBytes(bytes, secret) {
|
||||
const key = new TextEncoder().encode(secret);
|
||||
return bytes.map((byte, index) => byte ^ key[index % key.length]);
|
||||
}
|
||||
|
||||
/** Opaque token so QR URLs don't expose tenant code / branch id. */
|
||||
export function encodeBranchQrToken(tenantCode, branchId, secret = getQrSecret()) {
|
||||
const plain = `v1:${String(tenantCode).trim().toUpperCase()}:${Number(branchId)}`;
|
||||
const plainBytes = new TextEncoder().encode(plain);
|
||||
return toBase64Url(xorBytes(plainBytes, secret));
|
||||
}
|
||||
|
||||
export function decodeBranchQrToken(token, secret = getQrSecret()) {
|
||||
try {
|
||||
const plain = new TextDecoder().decode(xorBytes(fromBase64Url(token), secret));
|
||||
const match = /^v1:([^:]+):(\d+)$/.exec(plain);
|
||||
if (!match) return null;
|
||||
return {
|
||||
tenantCode: match[1],
|
||||
branchId: Number(match[2]),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getBranchTicketUrl(tenantCode, branchId) {
|
||||
const base = CUSTOMER_APP_URL.replace(/\/$/, '');
|
||||
const token = encodeBranchQrToken(tenantCode, branchId);
|
||||
return `${base}/q/${token}`;
|
||||
}
|
||||
|
||||
export async function generateBranchQrDataUrl(tenantCode, branchId) {
|
||||
return QRCode.toDataURL(getBranchTicketUrl(tenantCode, branchId), {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 2,
|
||||
width: 1024,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'branch';
|
||||
}
|
||||
|
||||
export function downloadDataUrl(dataUrl, filename) {
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
}
|
||||
|
||||
export async function downloadBranchQr({ tenantCode, branchId, branchName }) {
|
||||
const dataUrl = await generateBranchQrDataUrl(tenantCode, branchId);
|
||||
downloadDataUrl(dataUrl, `qr-${slugify(branchName)}-${branchId}.png`);
|
||||
return dataUrl;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
const TOKEN_KEY = 'token';
|
||||
const USER_DATA_KEY = 'userData';
|
||||
const IS_TFA_KEY = 'isTfa';
|
||||
|
||||
/** Matches backend jwt.token-validity-time (PT30M). */
|
||||
const SESSION_MAX_AGE_SECONDS = 30 * 60;
|
||||
|
||||
function getCookie(name) {
|
||||
const prefix = `${encodeURIComponent(name)}=`;
|
||||
const parts = document.cookie ? document.cookie.split('; ') : [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.startsWith(prefix)) {
|
||||
return decodeURIComponent(part.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCookie(name, value, maxAgeSeconds = SESSION_MAX_AGE_SECONDS) {
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
||||
document.cookie = [
|
||||
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
|
||||
`Path=/`,
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
`SameSite=Lax`,
|
||||
secure,
|
||||
].join('; ');
|
||||
}
|
||||
|
||||
function removeCookie(name) {
|
||||
document.cookie = `${encodeURIComponent(name)}=; Path=/; Max-Age=0; SameSite=Lax`;
|
||||
}
|
||||
|
||||
function migrateFromLocalStorage(key) {
|
||||
const legacy = localStorage.getItem(key);
|
||||
if (legacy == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setCookie(key, legacy);
|
||||
localStorage.removeItem(key);
|
||||
return legacy;
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return getCookie(TOKEN_KEY) ?? migrateFromLocalStorage(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
if (token == null || token === '') {
|
||||
removeCookie(TOKEN_KEY);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
setCookie(TOKEN_KEY, token);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getUserData() {
|
||||
const raw = getCookie(USER_DATA_KEY) ?? migrateFromLocalStorage(USER_DATA_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
removeCookie(USER_DATA_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setUserData(userData) {
|
||||
if (userData == null) {
|
||||
removeCookie(USER_DATA_KEY);
|
||||
localStorage.removeItem(USER_DATA_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
setCookie(USER_DATA_KEY, JSON.stringify(userData));
|
||||
localStorage.removeItem(USER_DATA_KEY);
|
||||
}
|
||||
|
||||
export function getIsTfa() {
|
||||
const raw = getCookie(IS_TFA_KEY) ?? migrateFromLocalStorage(IS_TFA_KEY);
|
||||
if (raw == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
export function setIsTfa(isTfa) {
|
||||
setCookie(IS_TFA_KEY, JSON.stringify(Boolean(isTfa)));
|
||||
localStorage.removeItem(IS_TFA_KEY);
|
||||
}
|
||||
|
||||
export function setSession({ token, userData, isTfa } = {}) {
|
||||
if (token !== undefined) {
|
||||
setToken(token);
|
||||
}
|
||||
if (userData !== undefined) {
|
||||
setUserData(userData);
|
||||
}
|
||||
if (isTfa !== undefined) {
|
||||
setIsTfa(isTfa);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
removeCookie(TOKEN_KEY);
|
||||
removeCookie(USER_DATA_KEY);
|
||||
removeCookie(IS_TFA_KEY);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_DATA_KEY);
|
||||
localStorage.removeItem(IS_TFA_KEY);
|
||||
}
|
||||
Reference in New Issue
Block a user