ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: устойчивый global logout и auth fallback
This commit is contained in:
parent
ed18a07154
commit
4400b7f438
|
|
@ -50,6 +50,7 @@ from .views import (
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# credentials
|
# credentials
|
||||||
|
path("", NodeDCOIDCInitiateEndpoint.as_view(), name="nodedc-auth-root"),
|
||||||
path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"),
|
path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"),
|
||||||
path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"),
|
path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"),
|
||||||
path("oidc/login/", NodeDCOIDCInitiateEndpoint.as_view(), name="nodedc-oidc-login"),
|
path("oidc/login/", NodeDCOIDCInitiateEndpoint.as_view(), name="nodedc-oidc-login"),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from urllib.parse import urlencode
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import requests
|
import requests
|
||||||
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
|
@ -53,6 +54,7 @@ class NodeDCOIDCInitiateEndpoint(View):
|
||||||
|
|
||||||
if request.GET.get("prompt") == "login":
|
if request.GET.get("prompt") == "login":
|
||||||
params["prompt"] = "login"
|
params["prompt"] = "login"
|
||||||
|
params["max_age"] = "0"
|
||||||
|
|
||||||
return HttpResponseRedirect(f"{discovery['authorization_endpoint']}?{urlencode(params)}")
|
return HttpResponseRedirect(f"{discovery['authorization_endpoint']}?{urlencode(params)}")
|
||||||
|
|
||||||
|
|
@ -65,17 +67,28 @@ class NodeDCOIDCCallbackEndpoint(View):
|
||||||
base_url = base_host(request=request, is_app=True)
|
base_url = base_host(request=request, is_app=True)
|
||||||
|
|
||||||
if request.GET.get("error"):
|
if request.GET.get("error"):
|
||||||
return oidc_error_redirect(base_url, next_path, "oidc_provider_error")
|
request.session.pop(OIDC_SESSION_KEY, None)
|
||||||
|
return oidc_login_redirect(base_url, next_path)
|
||||||
|
|
||||||
state = request.GET.get("state")
|
state = request.GET.get("state")
|
||||||
code = request.GET.get("code")
|
code = request.GET.get("code")
|
||||||
|
|
||||||
if not state or state != oidc_session.get("state") or not code:
|
if not state or state != oidc_session.get("state") or not code:
|
||||||
return oidc_error_redirect(base_url, next_path, "oidc_state_failed")
|
request.session.pop(OIDC_SESSION_KEY, None)
|
||||||
|
return oidc_login_redirect(base_url, next_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
discovery = load_discovery(config["issuer"])
|
||||||
|
token_set = exchange_code(discovery, config, code, oidc_session.get("code_verifier"))
|
||||||
|
claims = verify_id_token(discovery, config, token_set["id_token"], oidc_session.get("nonce"))
|
||||||
|
except (KeyError, RuntimeError, requests.RequestException, jwt.PyJWTError):
|
||||||
|
request.session.pop(OIDC_SESSION_KEY, None)
|
||||||
|
return oidc_login_redirect(base_url, next_path)
|
||||||
|
|
||||||
|
if is_logout_guard_active(claims):
|
||||||
|
request.session.pop(OIDC_SESSION_KEY, None)
|
||||||
|
return oidc_login_redirect(base_url, next_path, prompt_login=True)
|
||||||
|
|
||||||
discovery = load_discovery(config["issuer"])
|
|
||||||
token_set = exchange_code(discovery, config, code, oidc_session.get("code_verifier"))
|
|
||||||
claims = verify_id_token(discovery, config, token_set["id_token"], oidc_session.get("nonce"))
|
|
||||||
groups = normalize_groups(claims.get("groups"))
|
groups = normalize_groups(claims.get("groups"))
|
||||||
|
|
||||||
if not has_required_group(groups):
|
if not has_required_group(groups):
|
||||||
|
|
@ -260,5 +273,87 @@ def first_string_claim(claims, *keys):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_logout_guard_value(value):
|
||||||
|
return value.strip().lower() if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_logout_guard_cache_key(kind, value):
|
||||||
|
normalized_value = normalize_logout_guard_value(value)
|
||||||
|
return f"nodedc:logout-guard:{kind}:{normalized_value}" if normalized_value else ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_logout_guard_cache_keys(claims):
|
||||||
|
keys = set()
|
||||||
|
|
||||||
|
subject_key = get_logout_guard_cache_key("subject", claims.get("sub"))
|
||||||
|
email_key = get_logout_guard_cache_key("email", claims.get("email"))
|
||||||
|
|
||||||
|
if subject_key:
|
||||||
|
keys.add(subject_key)
|
||||||
|
if email_key:
|
||||||
|
keys.add(email_key)
|
||||||
|
|
||||||
|
return sorted(keys)
|
||||||
|
|
||||||
|
|
||||||
|
def get_claim_auth_time(claims):
|
||||||
|
auth_time = claims.get("auth_time")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return int(auth_time)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_logout_guard_time(claims):
|
||||||
|
guard_times = []
|
||||||
|
|
||||||
|
for cache_key in get_logout_guard_cache_keys(claims):
|
||||||
|
value = cache.get(cache_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
guard_times.append(int(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return max(guard_times) if guard_times else 0
|
||||||
|
|
||||||
|
|
||||||
|
def clear_logout_guard(claims):
|
||||||
|
cache_keys = get_logout_guard_cache_keys(claims)
|
||||||
|
|
||||||
|
if cache_keys:
|
||||||
|
cache.delete_many(cache_keys)
|
||||||
|
|
||||||
|
|
||||||
|
def is_logout_guard_active(claims):
|
||||||
|
logout_guard_time = get_logout_guard_time(claims)
|
||||||
|
|
||||||
|
if not logout_guard_time:
|
||||||
|
return False
|
||||||
|
|
||||||
|
auth_time = get_claim_auth_time(claims)
|
||||||
|
|
||||||
|
if auth_time > logout_guard_time:
|
||||||
|
clear_logout_guard(claims)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def oidc_error_redirect(base_url, next_path, error_code):
|
def oidc_error_redirect(base_url, next_path, error_code):
|
||||||
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=next_path, params={"error": error_code}))
|
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=next_path, params={"error": error_code}))
|
||||||
|
|
||||||
|
|
||||||
|
def oidc_login_redirect(base_url, next_path, prompt_login=False):
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
if validate_next_path(next_path):
|
||||||
|
params["next_path"] = next_path
|
||||||
|
|
||||||
|
if prompt_login:
|
||||||
|
params["prompt"] = "login"
|
||||||
|
|
||||||
|
query_string = f"?{urlencode(params)}" if params else ""
|
||||||
|
|
||||||
|
return HttpResponseRedirect(f"{base_url.rstrip('/')}/auth/oidc/login/{query_string}")
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
from secrets import compare_digest
|
||||||
|
|
||||||
from django.contrib.auth import logout
|
from django.contrib.auth import logout
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpResponse, HttpResponseRedirect
|
from django.core.cache import cache
|
||||||
|
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
from plane.authentication.utils.host import user_ip
|
from plane.authentication.utils.host import user_ip
|
||||||
from plane.db.models import User
|
from plane.db.models import ExternalIdentityLink, Session, User
|
||||||
|
|
||||||
|
|
||||||
|
OIDC_PROVIDER = "authentik"
|
||||||
|
DEFAULT_LOGOUT_GUARD_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
def get_nodedc_global_logout_url():
|
def get_nodedc_global_logout_url():
|
||||||
|
|
@ -19,10 +29,100 @@ def get_logout_redirect_url(default_url):
|
||||||
return get_nodedc_global_logout_url() or default_url
|
return get_nodedc_global_logout_url() or default_url
|
||||||
|
|
||||||
|
|
||||||
|
def get_nodedc_internal_token():
|
||||||
|
return (
|
||||||
|
os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip()
|
||||||
|
or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip()
|
||||||
|
or os.environ.get("PLANE_OIDC_CLIENT_SECRET", "").strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_internal_logout_request_authorized(request):
|
||||||
|
expected_token = get_nodedc_internal_token()
|
||||||
|
authorization = request.headers.get("Authorization", "")
|
||||||
|
bearer_token = (
|
||||||
|
authorization.split(" ", 1)[1].strip()
|
||||||
|
if authorization.lower().startswith("bearer ")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
header_token = request.headers.get("X-NODEDC-Internal-Token", "").strip()
|
||||||
|
request_token = bearer_token or header_token
|
||||||
|
|
||||||
|
return bool(expected_token and request_token and compare_digest(request_token, expected_token))
|
||||||
|
|
||||||
|
|
||||||
|
def get_logout_guard_seconds():
|
||||||
|
value = os.environ.get("PLANE_NODEDC_LOGOUT_GUARD_SECONDS", "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return max(1, int(value)) if value else DEFAULT_LOGOUT_GUARD_SECONDS
|
||||||
|
except ValueError:
|
||||||
|
return DEFAULT_LOGOUT_GUARD_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_guard_value(value):
|
||||||
|
return value.strip().lower() if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_logout_guard_cache_key(kind, value):
|
||||||
|
normalized_value = normalize_guard_value(value)
|
||||||
|
return f"nodedc:logout-guard:{kind}:{normalized_value}" if normalized_value else ""
|
||||||
|
|
||||||
|
|
||||||
|
def collect_logout_guard_keys(user=None, payload=None):
|
||||||
|
payload = payload or {}
|
||||||
|
keys = set()
|
||||||
|
|
||||||
|
for kind in ("subject", "email"):
|
||||||
|
cache_key = get_logout_guard_cache_key(kind, payload.get(kind))
|
||||||
|
if cache_key:
|
||||||
|
keys.add(cache_key)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
return keys
|
||||||
|
|
||||||
|
email_key = get_logout_guard_cache_key("email", user.email)
|
||||||
|
if email_key:
|
||||||
|
keys.add(email_key)
|
||||||
|
|
||||||
|
links = ExternalIdentityLink.objects.filter(
|
||||||
|
provider=OIDC_PROVIDER,
|
||||||
|
user=user,
|
||||||
|
status=ExternalIdentityLink.Status.ACTIVE,
|
||||||
|
).values_list("subject", "email")
|
||||||
|
|
||||||
|
for subject, email in links:
|
||||||
|
subject_key = get_logout_guard_cache_key("subject", subject)
|
||||||
|
email_key = get_logout_guard_cache_key("email", email)
|
||||||
|
|
||||||
|
if subject_key:
|
||||||
|
keys.add(subject_key)
|
||||||
|
if email_key:
|
||||||
|
keys.add(email_key)
|
||||||
|
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def mark_logout_guard(user=None, payload=None):
|
||||||
|
guard_keys = collect_logout_guard_keys(user=user, payload=payload)
|
||||||
|
|
||||||
|
if not guard_keys:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logout_time = int(time.time())
|
||||||
|
ttl = get_logout_guard_seconds()
|
||||||
|
|
||||||
|
for guard_key in guard_keys:
|
||||||
|
cache.set(guard_key, logout_time, timeout=ttl)
|
||||||
|
|
||||||
|
return sorted(guard_keys)
|
||||||
|
|
||||||
|
|
||||||
def logout_current_user(request):
|
def logout_current_user(request):
|
||||||
if request.user and request.user.is_authenticated:
|
if request.user and request.user.is_authenticated:
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(pk=request.user.id)
|
user = User.objects.get(pk=request.user.id)
|
||||||
|
mark_logout_guard(user=user)
|
||||||
user.last_logout_ip = user_ip(request=request)
|
user.last_logout_ip = user_ip(request=request)
|
||||||
user.last_logout_time = timezone.now()
|
user.last_logout_time = timezone.now()
|
||||||
user.save()
|
user.save()
|
||||||
|
|
@ -56,7 +156,8 @@ def clear_nodedc_auth_cookies(response, request=None):
|
||||||
if domain:
|
if domain:
|
||||||
session_cookie_name = getattr(settings, "SESSION_COOKIE_NAME", "session-id")
|
session_cookie_name = getattr(settings, "SESSION_COOKIE_NAME", "session-id")
|
||||||
response["Set-Cookie"] = (
|
response["Set-Cookie"] = (
|
||||||
f'{session_cookie_name}=""; Domain={domain}; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/'
|
f'{session_cookie_name}=""; Domain={domain}; '
|
||||||
|
"expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/"
|
||||||
)
|
)
|
||||||
|
|
||||||
response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
|
@ -65,11 +166,71 @@ def clear_nodedc_auth_cookies(response, request=None):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_body(request):
|
||||||
|
if not request.body:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return json.loads(request.body.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def find_logout_user(payload):
|
||||||
|
user_id = payload.get("planeUserId")
|
||||||
|
subject = payload.get("subject")
|
||||||
|
email = payload.get("email")
|
||||||
|
|
||||||
|
user_id = user_id.strip() if isinstance(user_id, str) else ""
|
||||||
|
subject = subject.strip() if isinstance(subject, str) else ""
|
||||||
|
email = email.strip().lower() if isinstance(email, str) else ""
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
user = User.objects.filter(id=user_id).first()
|
||||||
|
if user is not None:
|
||||||
|
return user
|
||||||
|
|
||||||
|
if subject:
|
||||||
|
link = ExternalIdentityLink.objects.filter(
|
||||||
|
provider=OIDC_PROVIDER,
|
||||||
|
subject=subject,
|
||||||
|
status=ExternalIdentityLink.Status.ACTIVE,
|
||||||
|
).select_related("user").first()
|
||||||
|
if link is not None:
|
||||||
|
return link.user
|
||||||
|
|
||||||
|
if email:
|
||||||
|
user = User.objects.filter(email__iexact=email).first()
|
||||||
|
if user is not None:
|
||||||
|
return user
|
||||||
|
|
||||||
|
link = ExternalIdentityLink.objects.filter(
|
||||||
|
provider=OIDC_PROVIDER,
|
||||||
|
email__iexact=email,
|
||||||
|
status=ExternalIdentityLink.Status.ACTIVE,
|
||||||
|
).select_related("user").first()
|
||||||
|
if link is not None:
|
||||||
|
return link.user
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_user_sessions(user, request):
|
||||||
|
deleted_count, _ = Session.objects.filter(user_id=str(user.id)).delete()
|
||||||
|
|
||||||
|
try:
|
||||||
|
user.last_logout_ip = user_ip(request=request)
|
||||||
|
user.last_logout_time = timezone.now()
|
||||||
|
user.save(update_fields=["last_logout_ip", "last_logout_time", "updated_at"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
|
||||||
class NodeDCFrontChannelLogoutEndpoint(View):
|
class NodeDCFrontChannelLogoutEndpoint(View):
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
logout_current_user(request)
|
logout_current_user(request)
|
||||||
response = HttpResponse(
|
response = HttpResponse(
|
||||||
"<!doctype html><html><head><meta charset='utf-8'></head><body>NODE.DC Task session closed.</body></html>",
|
"<!doctype html><html><head><meta charset='utf-8'></head>"
|
||||||
|
"<body>NODE.DC Task session closed.</body></html>",
|
||||||
content_type="text/html",
|
content_type="text/html",
|
||||||
)
|
)
|
||||||
return clear_nodedc_auth_cookies(response, request)
|
return clear_nodedc_auth_cookies(response, request)
|
||||||
|
|
@ -78,3 +239,46 @@ class NodeDCFrontChannelLogoutEndpoint(View):
|
||||||
logout_current_user(request)
|
logout_current_user(request)
|
||||||
response = HttpResponseRedirect(get_logout_redirect_url("/"))
|
response = HttpResponseRedirect(get_logout_redirect_url("/"))
|
||||||
return clear_nodedc_auth_cookies(response, request)
|
return clear_nodedc_auth_cookies(response, request)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
|
class NodeDCInternalSessionLogoutEndpoint(View):
|
||||||
|
http_method_names = ["post"]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
if not is_internal_logout_request_authorized(request):
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "internal_logout_unauthorized"
|
||||||
|
if get_nodedc_internal_token()
|
||||||
|
else "internal_logout_not_configured",
|
||||||
|
},
|
||||||
|
status=401 if get_nodedc_internal_token() else 503,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = parse_json_body(request)
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
|
||||||
|
|
||||||
|
user = find_logout_user(payload)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
mark_logout_guard(payload=payload)
|
||||||
|
return JsonResponse({"ok": True, "deletedSessions": 0, "user": None})
|
||||||
|
|
||||||
|
guard_keys = mark_logout_guard(user=user, payload=payload)
|
||||||
|
deleted_sessions = invalidate_user_sessions(user, request)
|
||||||
|
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"deletedSessions": deleted_sessions,
|
||||||
|
"guardKeys": len(guard_keys),
|
||||||
|
"user": {
|
||||||
|
"id": str(user.id),
|
||||||
|
"email": user.email,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,29 @@ from drf_spectacular.views import (
|
||||||
SpectacularRedocView,
|
SpectacularRedocView,
|
||||||
SpectacularSwaggerView,
|
SpectacularSwaggerView,
|
||||||
)
|
)
|
||||||
from plane.authentication.views.nodedc_logout import NodeDCFrontChannelLogoutEndpoint
|
from plane.authentication.views.nodedc_logout import (
|
||||||
|
NodeDCFrontChannelLogoutEndpoint,
|
||||||
|
NodeDCInternalSessionLogoutEndpoint,
|
||||||
|
)
|
||||||
|
|
||||||
handler404 = "plane.app.views.error_404.custom_404_view"
|
handler404 = "plane.app.views.error_404.custom_404_view"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"api/internal/nodedc/logout/",
|
||||||
|
NodeDCInternalSessionLogoutEndpoint.as_view(),
|
||||||
|
name="nodedc-internal-session-logout",
|
||||||
|
),
|
||||||
path("api/", include("plane.app.urls")),
|
path("api/", include("plane.app.urls")),
|
||||||
path("api/public/", include("plane.space.urls")),
|
path("api/public/", include("plane.space.urls")),
|
||||||
path("api/instances/", include("plane.license.urls")),
|
path("api/instances/", include("plane.license.urls")),
|
||||||
path("api/v1/", include("plane.api.urls")),
|
path("api/v1/", include("plane.api.urls")),
|
||||||
path("auth/", include("plane.authentication.urls")),
|
path("auth/", include("plane.authentication.urls")),
|
||||||
path("logout", NodeDCFrontChannelLogoutEndpoint.as_view(), name="nodedc-frontchannel-logout"),
|
path(
|
||||||
|
"logout",
|
||||||
|
NodeDCFrontChannelLogoutEndpoint.as_view(),
|
||||||
|
name="nodedc-frontchannel-logout",
|
||||||
|
),
|
||||||
path("", include("plane.web.urls")),
|
path("", include("plane.web.urls")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,5 @@ export function NodeDCAuthRedirect() {
|
||||||
window.location.replace(buildNodeDCOIDCLoginUrl(nextPath));
|
window.location.replace(buildNodeDCOIDCLoginUrl(nextPath));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return <div className="h-screen w-screen bg-canvas" aria-hidden="true" />;
|
||||||
<div className="relative z-10 flex h-screen w-screen flex-col items-center justify-center overflow-hidden bg-canvas px-8 py-12">
|
|
||||||
<div className="nodedc-auth-shell flex w-full max-w-[28rem] flex-col gap-4 text-center">
|
|
||||||
<div className="text-2xl font-semibold text-custom-text-100">Переходим в NODE.DC</div>
|
|
||||||
<div className="text-sm text-custom-text-300">Проверяем платформенную сессию и доступ к рабочему пространству.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue