feat(pwa): просмотр чужого экрана больше не выбрасывает из своего аккаунта
Сотрудник, вошедший водителем, при переключении на чужую карточку терял собственный вход: токен один на приложение, и «посмотреть, как выглядит у другого» означало заново входить через Telegram. Теперь свой сеанс откладывается и возвращается кнопкой «Вернуться к своему аккаунту». Откладывается ровно один раз — на переходе из обычного сеанса: смена водителя внутри просмотра не должна подменять отложенное чужим токеном. Заодно вход для сотрудника достижим изнутри приложения: длинный тап по названию в шапке ведёт на /dev. Ссылкой на экране это не делаем — водителю она ни к чему, а сотруднику иначе приходится набирать адрес руками. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,35 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
/** Долгое нажатие, после которого открывается вход для сотрудника. */
|
||||||
|
const LONG_PRESS_MS = 1500;
|
||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const timer = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Ссылки «для сотрудника» на экранах водителя нет намеренно: водителю она
|
||||||
|
// ничего не даёт, а место занимает. Но сотруднику, уже вошедшему водителем,
|
||||||
|
// адрес /dev иначе приходится набирать руками — отсюда долгий тап по названию.
|
||||||
|
const start = () => {
|
||||||
|
timer.current = window.setTimeout(() => nav("/dev"), LONG_PRESS_MS);
|
||||||
|
};
|
||||||
|
const cancel = () => {
|
||||||
|
if (timer.current) window.clearTimeout(timer.current);
|
||||||
|
timer.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="mb-4">
|
<header className="mb-4">
|
||||||
<span className="text-sm font-bold">Премиум Водитель</span>
|
<span
|
||||||
|
className="text-sm font-bold select-none"
|
||||||
|
onPointerDown={start}
|
||||||
|
onPointerUp={cancel}
|
||||||
|
onPointerLeave={cancel}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
Премиум Водитель
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Spinner } from "@/components/Spinner";
|
|||||||
* первый вход сотрудника (/dev) и смена водителя на вкладке «Отладка».
|
* первый вход сотрудника (/dev) и смена водителя на вкладке «Отладка».
|
||||||
*/
|
*/
|
||||||
export function DriverPicker({ onPicked }: { onPicked: (d: DriverBrief) => void }) {
|
export function DriverPicker({ onPicked }: { onPicked: (d: DriverBrief) => void }) {
|
||||||
const setSession = useAuth((s) => s.setSession);
|
const startImpersonation = useAuth((s) => s.startImpersonation);
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [list, setList] = useState<DriverBrief[]>([]);
|
const [list, setList] = useState<DriverBrief[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -35,7 +35,7 @@ export function DriverPicker({ onPicked }: { onPicked: (d: DriverBrief) => void
|
|||||||
setBusyId(d.id);
|
setBusyId(d.id);
|
||||||
try {
|
try {
|
||||||
const r = await impersonate(d.id);
|
const r = await impersonate(d.id);
|
||||||
setSession(r.token, r.driver.id, { impersonated: true, driverName: r.driver.fio });
|
startImpersonation(r.token, r.driver.id, r.driver.fio);
|
||||||
onPicked(r.driver);
|
onPicked(r.driver);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
toast.error(await apiErrorText(e, "Не удалось открыть приложение за водителя"));
|
toast.error(await apiErrorText(e, "Не удалось открыть приложение за водителя"));
|
||||||
|
|||||||
@@ -65,3 +65,35 @@ describe("вкладка отладки", () => {
|
|||||||
expect(screen.getByText("123 мс")).toBeInTheDocument();
|
expect(screen.getByText("123 мс")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("возврат к своему аккаунту", () => {
|
||||||
|
beforeEach(() => { useAuth.getState().clear(); useStaff.getState().clear(); clearLog(); });
|
||||||
|
|
||||||
|
it("свой вход не теряется при просмотре чужого экрана", () => {
|
||||||
|
useAuth.getState().setSession("мой-токен", 91);
|
||||||
|
useAuth.getState().startImpersonation("чужой-токен", 6866, "Тест Тестовый тестович");
|
||||||
|
let s = useAuth.getState();
|
||||||
|
expect([s.token, s.driverId, s.impersonated]).toEqual(["чужой-токен", 6866, true]);
|
||||||
|
|
||||||
|
// смена водителя внутри просмотра не должна подменять отложенный свой вход
|
||||||
|
useAuth.getState().startImpersonation("третий-токен", 2869, "Тестик Николай");
|
||||||
|
expect(useAuth.getState().own).toEqual({ token: "мой-токен", driverId: 91 });
|
||||||
|
|
||||||
|
expect(useAuth.getState().stopImpersonation()).toBe(true);
|
||||||
|
s = useAuth.getState();
|
||||||
|
expect([s.token, s.driverId, s.impersonated, s.own]).toEqual(["мой-токен", 91, false, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("если своего входа не было — просмотр просто заканчивается выходом", () => {
|
||||||
|
useAuth.getState().startImpersonation("чужой-токен", 6866, "Тест");
|
||||||
|
expect(useAuth.getState().stopImpersonation()).toBe(false);
|
||||||
|
expect(useAuth.getState().token).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("кнопка предлагает вернуться к себе, когда есть куда", async () => {
|
||||||
|
useAuth.getState().setSession("мой-токен", 91);
|
||||||
|
useAuth.getState().startImpersonation("чужой-токен", 6866, "Тест Тестовый тестович");
|
||||||
|
renderAt("/debug");
|
||||||
|
expect(screen.getByRole("button", { name: /Вернуться к своему аккаунту/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function Row({ k, v }: { k: string; v: string }) {
|
|||||||
*/
|
*/
|
||||||
export function DebugPage() {
|
export function DebugPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const { token, driverId, driverName, impersonated, clear } = useAuth();
|
const { token, driverId, driverName, impersonated, own, clear, stopImpersonation } = useAuth();
|
||||||
const staff = useStaff();
|
const staff = useStaff();
|
||||||
const log = useSyncExternalStore(subscribe, getLog);
|
const log = useSyncExternalStore(subscribe, getLog);
|
||||||
const [switching, setSwitching] = useState(false);
|
const [switching, setSwitching] = useState(false);
|
||||||
@@ -112,9 +112,16 @@ export function DebugPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn-ghost flex items-center justify-center gap-2"
|
className="btn-ghost flex items-center justify-center gap-2"
|
||||||
onClick={() => { clear(); nav(staff.token ? "/dev" : "/login"); }}
|
onClick={() => {
|
||||||
|
// Свой вход, отложенный на время просмотра, возвращаем на место: иначе
|
||||||
|
// «посмотреть чужой экран» означало бы заново входить через Telegram.
|
||||||
|
if (impersonated && stopImpersonation()) { nav("/"); return; }
|
||||||
|
clear();
|
||||||
|
nav(staff.token ? "/dev" : "/login");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<LogOut size={16} /> {impersonated ? "Выйти из просмотра" : "Выйти"}
|
<LogOut size={16} />{" "}
|
||||||
|
{impersonated ? (own ? "Вернуться к своему аккаунту" : "Выйти из просмотра") : "Выйти"}
|
||||||
</button>
|
</button>
|
||||||
{staff.token && (
|
{staff.token && (
|
||||||
<button className="text-muted text-xs w-full mt-4"
|
<button className="text-muted text-xs w-full mt-4"
|
||||||
|
|||||||
@@ -10,20 +10,33 @@ export interface ViewAs {
|
|||||||
driverName: string | null;
|
driverName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Собственный сеанс, отложенный на время просмотра чужого. */
|
||||||
|
interface OwnSession {
|
||||||
|
token: string;
|
||||||
|
driverId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthState extends ViewAs {
|
interface AuthState extends ViewAs {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
driverId: number | null;
|
driverId: number | null;
|
||||||
|
/** Сеанс, к которому вернёт «выйти из просмотра». null — возвращаться некуда. */
|
||||||
|
own: OwnSession | null;
|
||||||
setSession: (token: string, driverId: number, view?: Partial<ViewAs>) => void;
|
setSession: (token: string, driverId: number, view?: Partial<ViewAs>) => void;
|
||||||
|
/** Начать просмотр от лица водителя, отложив собственный вход. */
|
||||||
|
startImpersonation: (token: string, driverId: number, driverName: string) => void;
|
||||||
|
/** Вернуться к себе. true — вернулись, false — своего входа не было. */
|
||||||
|
stopImpersonation: () => boolean;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuth = create<AuthState>()(
|
export const useAuth = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set, get) => ({
|
||||||
token: null,
|
token: null,
|
||||||
driverId: null,
|
driverId: null,
|
||||||
impersonated: false,
|
impersonated: false,
|
||||||
driverName: null,
|
driverName: null,
|
||||||
|
own: null,
|
||||||
setSession: (token, driverId, view) =>
|
setSession: (token, driverId, view) =>
|
||||||
set({
|
set({
|
||||||
token,
|
token,
|
||||||
@@ -31,7 +44,35 @@ export const useAuth = create<AuthState>()(
|
|||||||
impersonated: view?.impersonated ?? false,
|
impersonated: view?.impersonated ?? false,
|
||||||
driverName: view?.driverName ?? null,
|
driverName: view?.driverName ?? null,
|
||||||
}),
|
}),
|
||||||
clear: () => set({ token: null, driverId: null, impersonated: false, driverName: null }),
|
startImpersonation: (token, driverId, driverName) => {
|
||||||
|
const s = get();
|
||||||
|
// Свой вход откладываем ОДИН раз — при переходе из обычного сеанса. Смена
|
||||||
|
// водителя внутри просмотра не должна затирать отложенное собственным
|
||||||
|
// чужим токеном, иначе «вернуться к себе» вернёт к предыдущему водителю.
|
||||||
|
const own = s.impersonated
|
||||||
|
? s.own
|
||||||
|
: s.token
|
||||||
|
? { token: s.token, driverId: s.driverId }
|
||||||
|
: null;
|
||||||
|
set({ token, driverId, impersonated: true, driverName, own });
|
||||||
|
},
|
||||||
|
stopImpersonation: () => {
|
||||||
|
const own = get().own;
|
||||||
|
if (!own) {
|
||||||
|
set({ token: null, driverId: null, impersonated: false, driverName: null });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
token: own.token,
|
||||||
|
driverId: own.driverId,
|
||||||
|
impersonated: false,
|
||||||
|
driverName: null,
|
||||||
|
own: null,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
clear: () =>
|
||||||
|
set({ token: null, driverId: null, impersonated: false, driverName: null, own: null }),
|
||||||
}),
|
}),
|
||||||
{ name: "pp-driver-auth" }
|
{ name: "pp-driver-auth" }
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user