Use saved SMB credentials for AI structure
This commit is contained in:
@@ -23,6 +23,8 @@ def prepare_ai_structure(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
structure_only: bool = False,
|
||||
display_input_path: str | None = None,
|
||||
display_output_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not input_path.exists():
|
||||
raise FileNotFoundError(f"Input path not found: {input_path}")
|
||||
@@ -48,7 +50,17 @@ def prepare_ai_structure(
|
||||
diagnostics.append("No 1C metadata/XML/BSL files or .cf/.cfe binaries were found.")
|
||||
|
||||
codex_root = output_path / _codex_folder_name(project_id)
|
||||
manifest = _manifest(project_id, input_path, output_path, codex_root, files, snapshot, normalized, diagnostics, binaries)
|
||||
manifest = _manifest(
|
||||
project_id,
|
||||
display_input_path or str(input_path),
|
||||
display_output_path or str(output_path),
|
||||
_join_display_path(display_output_path, codex_root.name) if display_output_path else str(codex_root),
|
||||
files,
|
||||
snapshot,
|
||||
normalized,
|
||||
diagnostics,
|
||||
binaries,
|
||||
)
|
||||
_write_json(output_path / "manifest.json", manifest)
|
||||
_write_json(output_path / "source_inventory.json", {"files": files})
|
||||
if snapshot is not None:
|
||||
@@ -78,9 +90,9 @@ def _inventory(root: Path) -> list[dict[str, Any]]:
|
||||
|
||||
def _manifest(
|
||||
project_id: str,
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
codex_root: Path,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
codex_root: str,
|
||||
files: list[dict[str, Any]],
|
||||
snapshot: SirSnapshot | None,
|
||||
normalized: NormalizedProject | None,
|
||||
@@ -90,10 +102,10 @@ def _manifest(
|
||||
return {
|
||||
"version": AI_STRUCTURE_VERSION,
|
||||
"project_id": project_id,
|
||||
"input_path": str(input_path),
|
||||
"output_path": str(output_path),
|
||||
"codex_package_path": str(codex_root),
|
||||
"codex_package_folder": codex_root.name,
|
||||
"input_path": input_path,
|
||||
"output_path": output_path,
|
||||
"codex_package_path": codex_root,
|
||||
"codex_package_folder": Path(codex_root).name if not codex_root.startswith("\\\\") else codex_root.rstrip("\\").rsplit("\\", 1)[-1],
|
||||
"status": "ready" if snapshot is not None or normalized is not None else "export_required",
|
||||
"files_count": len(files),
|
||||
"binary_1c_files": binaries,
|
||||
@@ -135,6 +147,14 @@ def _codex_folder_name(project_id: str) -> str:
|
||||
return f"codex-1c-context-{safe}"
|
||||
|
||||
|
||||
def _join_display_path(root: str | None, child: str) -> str:
|
||||
if not root:
|
||||
return child
|
||||
separator = "\\" if root.startswith("\\\\") or "\\" in root else "/"
|
||||
cleaned = root.rstrip("/\\")
|
||||
return f"{cleaned}{separator}{child}"
|
||||
|
||||
|
||||
def _write_codex_package(
|
||||
root: Path,
|
||||
input_path: Path,
|
||||
|
||||
@@ -7,7 +7,13 @@ from urllib.parse import quote
|
||||
from api_server.html5 import _page, _project_link, _topbar
|
||||
|
||||
|
||||
def render_html5_ai_structure_page(*, project_id: str, projects: Iterable[object], result: dict | None = None) -> str:
|
||||
def render_html5_ai_structure_page(
|
||||
*,
|
||||
project_id: str,
|
||||
projects: Iterable[object],
|
||||
result: dict | None = None,
|
||||
saved_credentials: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
project_nav = "\n".join(_project_link(project, project_id) for project in projects)
|
||||
return _page(
|
||||
f"SFERA AI Structure - {project_id}",
|
||||
@@ -24,7 +30,7 @@ def render_html5_ai_structure_page(*, project_id: str, projects: Iterable[object
|
||||
</aside>
|
||||
<section class="panel setup-main">
|
||||
<div class="panel-title">Подготовка структуры</div>
|
||||
{render_html5_ai_structure_form(project_id)}
|
||||
{render_html5_ai_structure_form(project_id, saved_credentials=saved_credentials)}
|
||||
<p class="object-summary">Пути должны быть доступны серверу SFERA/API. Для docker-test используйте папку, смонтированную или доступную внутри контейнера; локальные диски Windows и закрытые SMB-папки без учетных данных сервер не увидит.</p>
|
||||
<div data-html5-ai-structure-result>{render_html5_ai_structure_result(result)}</div>
|
||||
</section>
|
||||
@@ -34,7 +40,11 @@ def render_html5_ai_structure_page(*, project_id: str, projects: Iterable[object
|
||||
)
|
||||
|
||||
|
||||
def render_html5_ai_structure_form(project_id: str) -> str:
|
||||
def render_html5_ai_structure_form(project_id: str, *, saved_credentials: dict[str, str] | None = None) -> str:
|
||||
saved_credentials = saved_credentials or {}
|
||||
saved_username = str(saved_credentials.get("username") or "")
|
||||
saved_domain = str(saved_credentials.get("domain") or "")
|
||||
password_hint = "Пароль сохранен, оставьте пустым чтобы использовать его" if saved_credentials.get("password") else "Пароль SMB"
|
||||
return f"""
|
||||
<form
|
||||
class="ai-structure-form"
|
||||
@@ -54,6 +64,22 @@ def render_html5_ai_structure_form(project_id: str) -> str:
|
||||
<span>Project id</span>
|
||||
<input name="project_id" value="{escape(project_id)}" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Домен</span>
|
||||
<input name="smb_domain" value="{escape(saved_domain)}" autocomplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Логин SMB</span>
|
||||
<input name="smb_username" value="{escape(saved_username)}" autocomplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Пароль SMB</span>
|
||||
<input name="smb_password" type="password" placeholder="{escape(password_hint)}" autocomplete="current-password" />
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input name="save_smb_credentials" type="checkbox" value="1" checked />
|
||||
<span>Сохранить</span>
|
||||
</label>
|
||||
<button class="primary" type="submit">Подготовить для ИИ</button>
|
||||
</form>
|
||||
"""
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -12,14 +13,23 @@ from api_server.html5_ai_structure import (
|
||||
render_html5_ai_structure_result,
|
||||
)
|
||||
from api_server.html5_forms import form_value
|
||||
from api_server.smb_paths import copy_local_tree_to_smb, copy_smb_tree_to_local, is_unc_path, remove_tree
|
||||
|
||||
|
||||
SmbCredentials = dict[str, str]
|
||||
|
||||
|
||||
def html5_ai_structure_page(
|
||||
*,
|
||||
project_id: str,
|
||||
project_summaries: Callable[[], Iterable[object]],
|
||||
load_credentials: Callable[[str], SmbCredentials | None] | None = None,
|
||||
) -> str:
|
||||
return render_html5_ai_structure_page(project_id=project_id, projects=project_summaries())
|
||||
return render_html5_ai_structure_page(
|
||||
project_id=project_id,
|
||||
projects=project_summaries(),
|
||||
saved_credentials=load_credentials(project_id) if load_credentials else None,
|
||||
)
|
||||
|
||||
|
||||
def html5_ai_structure_run(
|
||||
@@ -27,6 +37,9 @@ def html5_ai_structure_run(
|
||||
project_id: str,
|
||||
form: dict[str, list[str]],
|
||||
prepare: Callable[..., dict[str, Any]],
|
||||
work_root: Path,
|
||||
load_credentials: Callable[[str], SmbCredentials | None] | None = None,
|
||||
save_credentials: Callable[[str, SmbCredentials], None] | None = None,
|
||||
) -> str:
|
||||
effective_project_id = form_value(form, "project_id") or project_id
|
||||
input_path = form_value(form, "input_path")
|
||||
@@ -35,12 +48,54 @@ def html5_ai_structure_run(
|
||||
return render_html5_ai_structure_error("Заполните входную папку с .cf/.cfe или выгрузкой 1С.")
|
||||
if not output_path:
|
||||
return render_html5_ai_structure_error("Заполните папку результата.")
|
||||
saved = load_credentials(project_id) if load_credentials else None
|
||||
username = form_value(form, "smb_username") or (saved or {}).get("username", "")
|
||||
password = form_value(form, "smb_password") or (saved or {}).get("password", "")
|
||||
domain = form_value(form, "smb_domain") or (saved or {}).get("domain", "")
|
||||
should_save = bool(form_value(form, "save_smb_credentials"))
|
||||
uses_smb = is_unc_path(input_path) or is_unc_path(output_path)
|
||||
if uses_smb and (not username or not password):
|
||||
return render_html5_ai_structure_error("Для сетевого UNC-пути укажите логин и пароль SMB.")
|
||||
if should_save and save_credentials and username and password:
|
||||
save_credentials(project_id, {"username": username, "password": password, "domain": domain})
|
||||
|
||||
work_dir = work_root / f"{effective_project_id}-{uuid4().hex}"
|
||||
try:
|
||||
result = prepare(project_id=effective_project_id, input_path=Path(input_path), output_path=Path(output_path))
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
local_input = work_dir / "input" if is_unc_path(input_path) else Path(input_path)
|
||||
local_output = work_dir / "output" if is_unc_path(output_path) else Path(output_path)
|
||||
if is_unc_path(input_path):
|
||||
copy_smb_tree_to_local(
|
||||
source=input_path,
|
||||
target=local_input,
|
||||
username=username,
|
||||
password=password,
|
||||
domain=domain or None,
|
||||
)
|
||||
result = prepare(
|
||||
project_id=effective_project_id,
|
||||
input_path=local_input,
|
||||
output_path=local_output,
|
||||
display_input_path=input_path,
|
||||
display_output_path=output_path,
|
||||
)
|
||||
if is_unc_path(output_path):
|
||||
copy_local_tree_to_smb(
|
||||
source=local_output,
|
||||
target=output_path,
|
||||
username=username,
|
||||
password=password,
|
||||
domain=domain or None,
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
return render_html5_ai_structure_error(str(error))
|
||||
except PermissionError as error:
|
||||
return render_html5_ai_structure_error(f"Нет доступа к папке: {error}")
|
||||
except OSError as error:
|
||||
return render_html5_ai_structure_error(f"Ошибка файловой системы: {error}")
|
||||
except RuntimeError as error:
|
||||
return render_html5_ai_structure_error(str(error))
|
||||
finally:
|
||||
if work_dir.exists():
|
||||
remove_tree(work_dir, expected_parent=work_root)
|
||||
return render_html5_ai_structure_result(result)
|
||||
|
||||
@@ -284,6 +284,40 @@ _neo4j_user = os.environ.get("NEO4J_USER", "neo4j")
|
||||
_neo4j_password = os.environ.get("NEO4J_PASSWORD", "password")
|
||||
_EVENT_SUBSCRIPTION_KIND = getattr(NodeKind, "EVENT_SUBSCRIPTION", None)
|
||||
|
||||
|
||||
def _load_ai_structure_smb_credentials(project_id: str) -> dict[str, str] | None:
|
||||
try:
|
||||
payload = _storage.read_document("ai_structure_smb_credentials", project_id)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
password_value = str(payload.get("password_b64") or "")
|
||||
try:
|
||||
password = base64.b64decode(password_value.encode("ascii")).decode("utf-8") if password_value else ""
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
password = ""
|
||||
username = str(payload.get("username") or "")
|
||||
if not username and not password:
|
||||
return None
|
||||
return {
|
||||
"username": username,
|
||||
"domain": str(payload.get("domain") or ""),
|
||||
"password": password,
|
||||
}
|
||||
|
||||
|
||||
def _save_ai_structure_smb_credentials(project_id: str, credentials: dict[str, str]) -> None:
|
||||
password = str(credentials.get("password") or "")
|
||||
_storage.write_document(
|
||||
"ai_structure_smb_credentials",
|
||||
project_id,
|
||||
{
|
||||
"project_id": project_id,
|
||||
"username": str(credentials.get("username") or ""),
|
||||
"domain": str(credentials.get("domain") or ""),
|
||||
"password_b64": base64.b64encode(password.encode("utf-8")).decode("ascii"),
|
||||
},
|
||||
)
|
||||
|
||||
_ACCESS_TARGET_KINDS = {
|
||||
NodeKind.CATALOG,
|
||||
NodeKind.DOCUMENT,
|
||||
@@ -1585,7 +1619,11 @@ async def html5_project_access(project_id: str, profile: str | None = None) -> R
|
||||
@app.get("/html5/projects/{project_id}/ai-structure")
|
||||
async def html5_project_ai_structure(project_id: str) -> Response:
|
||||
return _html5_response(
|
||||
_html5_ai_structure_page(project_id=project_id, project_summaries=_project_summaries)
|
||||
_html5_ai_structure_page(
|
||||
project_id=project_id,
|
||||
project_summaries=_project_summaries,
|
||||
load_credentials=_load_ai_structure_smb_credentials,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1597,6 +1635,9 @@ async def html5_project_ai_structure_run(project_id: str, request: Request) -> R
|
||||
project_id=project_id,
|
||||
form=form,
|
||||
prepare=_prepare_ai_structure,
|
||||
work_root=_storage.root / "ai_structure_work",
|
||||
load_credentials=_load_ai_structure_smb_credentials,
|
||||
save_credentials=_save_ai_structure_smb_credentials,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def is_unc_path(path: str) -> bool:
|
||||
return path.startswith("\\\\")
|
||||
|
||||
|
||||
def copy_smb_tree_to_local(
|
||||
*,
|
||||
source: str,
|
||||
target: Path,
|
||||
username: str,
|
||||
password: str,
|
||||
domain: str | None = None,
|
||||
) -> None:
|
||||
smbclient = _smbclient()
|
||||
server, _share, _relative = parse_unc_path(source)
|
||||
_register_session(smbclient, server=server, username=username, password=password, domain=domain)
|
||||
_copy_smb_directory(smbclient, source.rstrip("\\"), target)
|
||||
|
||||
|
||||
def copy_local_tree_to_smb(
|
||||
*,
|
||||
source: Path,
|
||||
target: str,
|
||||
username: str,
|
||||
password: str,
|
||||
domain: str | None = None,
|
||||
) -> None:
|
||||
smbclient = _smbclient()
|
||||
server, _share, _relative = parse_unc_path(target)
|
||||
_register_session(smbclient, server=server, username=username, password=password, domain=domain)
|
||||
remote_root = target.rstrip("\\")
|
||||
_ensure_smb_directory(smbclient, remote_root)
|
||||
if source.is_file():
|
||||
_copy_local_file_to_smb(smbclient, source, f"{remote_root}\\{source.name}")
|
||||
return
|
||||
for path in sorted(source.rglob("*")):
|
||||
relative = path.relative_to(source)
|
||||
remote_relative = str(relative).replace("/", "\\")
|
||||
remote = f"{remote_root}\\{remote_relative}"
|
||||
if path.is_dir():
|
||||
_ensure_smb_directory(smbclient, remote)
|
||||
elif path.is_file():
|
||||
_ensure_smb_directory(smbclient, remote.rsplit("\\", 1)[0])
|
||||
_copy_local_file_to_smb(smbclient, path, remote)
|
||||
|
||||
|
||||
def parse_unc_path(path: str) -> tuple[str, str, str]:
|
||||
parts = [part for part in path.strip("\\").split("\\") if part]
|
||||
if len(parts) < 2:
|
||||
raise ValueError("UNC путь должен содержать сервер и share: \\\\server\\share.")
|
||||
server, share = parts[0], parts[1]
|
||||
relative = "\\".join(parts[2:])
|
||||
return server, share, relative
|
||||
|
||||
|
||||
def remove_tree(path: Path, *, expected_parent: Path) -> None:
|
||||
resolved = path.resolve()
|
||||
parent = expected_parent.resolve()
|
||||
if resolved == parent or parent not in resolved.parents:
|
||||
raise ValueError(f"Refusing to remove path outside work root: {resolved}")
|
||||
if resolved.exists():
|
||||
shutil.rmtree(resolved)
|
||||
|
||||
|
||||
def _smbclient() -> Any:
|
||||
try:
|
||||
import smbclient
|
||||
except ImportError as error:
|
||||
raise RuntimeError("SMB client dependency is not installed on the API server.") from error
|
||||
return smbclient
|
||||
|
||||
|
||||
def _register_session(smbclient: Any, *, server: str, username: str, password: str, domain: str | None) -> None:
|
||||
qualified_user = f"{domain}\\{username}" if domain else username
|
||||
smbclient.register_session(server, username=qualified_user, password=password)
|
||||
|
||||
|
||||
def _copy_smb_directory(smbclient: Any, source: str, target: Path) -> None:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for item in smbclient.scandir(source):
|
||||
destination = target / item.name
|
||||
child_source = f"{source}\\{item.name}"
|
||||
try:
|
||||
if item.is_dir():
|
||||
_copy_smb_directory(smbclient, child_source, destination)
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
with smbclient.open_file(child_source, mode="rb") as remote_file:
|
||||
with destination.open("wb") as local_file:
|
||||
shutil.copyfileobj(remote_file, local_file, length=1024 * 1024)
|
||||
|
||||
|
||||
def _copy_local_file_to_smb(smbclient: Any, source: Path, target: str) -> None:
|
||||
with source.open("rb") as local_file:
|
||||
with smbclient.open_file(target, mode="wb") as remote_file:
|
||||
shutil.copyfileobj(local_file, remote_file, length=1024 * 1024)
|
||||
|
||||
|
||||
def _ensure_smb_directory(smbclient: Any, path: str) -> None:
|
||||
normalized = path.rstrip("\\")
|
||||
try:
|
||||
if smbclient.path.isdir(normalized):
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
parent = _unc_parent_path(normalized)
|
||||
if parent and parent != normalized:
|
||||
_ensure_smb_directory(smbclient, parent)
|
||||
try:
|
||||
smbclient.mkdir(normalized)
|
||||
except OSError:
|
||||
if not smbclient.path.isdir(normalized):
|
||||
raise
|
||||
|
||||
|
||||
def _unc_parent_path(path: str) -> str | None:
|
||||
server, share, relative = parse_unc_path(path)
|
||||
if not relative:
|
||||
return None
|
||||
parts = [part for part in relative.split("\\") if part]
|
||||
if len(parts) <= 1:
|
||||
return f"\\\\{server}\\{share}"
|
||||
parent_relative = "\\".join(parts[:-1])
|
||||
return f"\\\\{server}\\{share}\\{parent_relative}"
|
||||
@@ -13,7 +13,7 @@
|
||||
.access-workspace{background:#f4f7fb}.access-layout{display:grid;grid-template-columns:300px minmax(0,1fr)340px;height:calc(100vh - 88px)}.access-main{border-top:0;border-bottom:0;overflow:auto}.access-nav,.access-side{overflow:auto}.tree-item[data-html5-access-profile-selected="true"]{background:#f8fbff;border-left:3px solid var(--brand);padding-left:9px}.access-empty{margin:16px;border:1px dashed #aeb8c6;background:#fff;padding:18px;color:#687385}.access-empty strong,.access-empty span{display:block}.access-empty strong{color:#1f2937}.access-profile,.access-plan,.access-result{border-bottom:1px solid var(--line);background:#fff}.access-summary{display:flex;gap:8px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--line);background:#f8fbff;color:#687385;font-size:12px;font-weight:800}.access-role-grid,.access-operations,.access-list{display:grid}.access-role-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.access-card{display:grid;gap:3px;min-width:0;padding:10px 12px;border-bottom:1px solid var(--line)}.access-role-grid .access-card:nth-child(odd){border-right:1px solid var(--line)}.access-card strong,.access-card small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.access-card small{color:var(--muted)}.access-plan-head{display:flex;gap:10px;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-plan-head form{margin:0}.access-actions{padding:12px}.access-warnings{margin:0;padding:0;list-style:none}.access-warnings li{padding:8px 12px;border-bottom:1px solid var(--line);color:var(--warn);font-weight:800}.access-json{margin:0;max-height:220px;overflow:auto;padding:12px;background:#fbfaf7;border-top:1px solid var(--line);font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}.access-main .primary{background:var(--brand);border-color:var(--brand);color:#fff}
|
||||
.access-builder{border-bottom:1px solid var(--line);background:#fff}.access-builder-form{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-builder-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.access-builder-form input,.access-builder-form textarea{min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.access-builder-form input{height:32px}.access-builder-form textarea{min-height:68px;padding:8px;resize:vertical}.access-builder-actions{grid-column:1/-1;display:flex;gap:8px;justify-content:flex-end}.access-builder-result{border-top:1px solid var(--line);background:#fff}
|
||||
.access-card[hx-get]{cursor:pointer}.access-card[hx-get]:hover{background:#f8fbff}.access-user-detail{border-bottom:1px solid var(--line);background:#fff}
|
||||
.ai-structure-form{display:grid;grid-template-columns:1fr 1fr 220px auto;gap:10px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.ai-structure-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.ai-structure-form input{height:32px;min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.ai-structure-result{background:#fff}
|
||||
.ai-structure-form{display:grid;grid-template-columns:1.4fr 1.4fr 180px 120px 180px 180px 120px auto;gap:10px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.ai-structure-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.ai-structure-form input{height:32px;min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.ai-structure-form .checkbox-row{display:flex;align-items:center;gap:7px;height:32px}.ai-structure-form .checkbox-row input{width:16px;height:16px}.ai-structure-result{background:#fff}
|
||||
@media(max-width:980px){.layout{grid-template-columns:1fr;height:auto}.tree,.inspector{max-height:320px}.editor{min-height:520px}.hero{display:block}.shell{padding:16px}}
|
||||
@media(max-width:980px){.access-layout{grid-template-columns:1fr;height:auto}.access-nav,.access-side{max-height:360px}.access-role-grid{grid-template-columns:1fr}.access-role-grid .access-card:nth-child(odd){border-right:0}}
|
||||
@media(max-width:980px){.access-builder-form{grid-template-columns:1fr}.access-builder-actions{justify-content:stretch}.access-builder-actions button{flex:1}}
|
||||
|
||||
Reference in New Issue
Block a user