appmanager-server 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. appmanager/__init__.py +52 -0
  2. appmanager/admin/__init__.py +3 -0
  3. appmanager/admin/app_installer.py +739 -0
  4. appmanager/admin/members.py +95 -0
  5. appmanager/admin/registry.py +372 -0
  6. appmanager/admin/routes.py +900 -0
  7. appmanager/api/__init__.py +5 -0
  8. appmanager/api/routes.py +373 -0
  9. appmanager/app_config.py +101 -0
  10. appmanager/auth/__init__.py +3 -0
  11. appmanager/auth/oauth.py +77 -0
  12. appmanager/auth/routes.py +224 -0
  13. appmanager/auth/utils.py +175 -0
  14. appmanager/bridge.py +90 -0
  15. appmanager/cli.py +1171 -0
  16. appmanager/config.py +58 -0
  17. appmanager/database.py +120 -0
  18. appmanager/extension.py +198 -0
  19. appmanager/extensions.py +225 -0
  20. appmanager/health.py +133 -0
  21. appmanager/hooks.py +187 -0
  22. appmanager/middleware.py +256 -0
  23. appmanager/models.py +307 -0
  24. appmanager/py.typed +1 -0
  25. appmanager/sdk.py +30 -0
  26. appmanager/security.py +227 -0
  27. appmanager/security_scanner.py +582 -0
  28. appmanager/signals.py +21 -0
  29. appmanager/static/css/app.css +808 -0
  30. appmanager/static/vendor/fonts/fonts.css +288 -0
  31. appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
  32. appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
  33. appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
  34. appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
  35. appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
  36. appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
  37. appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
  38. appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
  39. appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
  40. appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
  41. appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
  42. appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
  43. appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
  44. appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
  45. appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
  46. appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
  47. appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
  48. appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
  49. appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
  50. appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
  51. appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
  52. appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
  53. appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
  54. appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
  55. appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
  56. appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
  57. appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
  58. appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
  59. appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
  60. appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
  61. appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
  62. appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
  63. appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
  64. appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
  65. appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
  66. appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
  67. appmanager/static/vendor/htmx/htmx.min.js +1 -0
  68. appmanager/templates/admin/_member_drawer.html +98 -0
  69. appmanager/templates/admin/_members_table.html +108 -0
  70. appmanager/templates/admin/app_detail.html +136 -0
  71. appmanager/templates/admin/dashboard.html +902 -0
  72. appmanager/templates/admin/flairs.html +90 -0
  73. appmanager/templates/admin/permissions.html +88 -0
  74. appmanager/templates/admin/roles.html +119 -0
  75. appmanager/templates/admin/users.html +156 -0
  76. appmanager/templates/auth/login.html +58 -0
  77. appmanager/templates/auth/profile.html +44 -0
  78. appmanager/templates/base.html +76 -0
  79. appmanager/templates/errors/401.html +19 -0
  80. appmanager/templates/errors/403.html +19 -0
  81. appmanager/templates/errors/404.html +18 -0
  82. appmanager/templates/errors/500.html +19 -0
  83. appmanager/templates/index.html +54 -0
  84. appmanager_server-0.2.0.dist-info/METADATA +320 -0
  85. appmanager_server-0.2.0.dist-info/RECORD +89 -0
  86. appmanager_server-0.2.0.dist-info/WHEEL +5 -0
  87. appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
  88. appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
  89. appmanager_server-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,224 @@
1
+ from datetime import datetime, timezone
2
+
3
+ from flask import (
4
+ Blueprint,
5
+ current_app,
6
+ flash,
7
+ make_response,
8
+ redirect,
9
+ render_template,
10
+ request,
11
+ url_for,
12
+ )
13
+
14
+ from appmanager.auth.oauth import get_google_auth_url, process_google_callback
15
+ from appmanager.auth.utils import (
16
+ JWT_COOKIE_NAME,
17
+ create_magic_link,
18
+ generate_jwt,
19
+ get_current_user,
20
+ login_required,
21
+ )
22
+ from appmanager.database import db
23
+ from appmanager.models import InstalledApp, MagicLinkToken, User, UserAppPermission
24
+ from appmanager.security import check_rate_limit, is_safe_redirect_url
25
+
26
+ auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
27
+
28
+
29
+ @auth_bp.route("/login", methods=["GET"])
30
+ def login():
31
+ user = get_current_user()
32
+ if user:
33
+ return redirect(url_for("auth.profile"))
34
+
35
+ next_url = request.args.get("next", "")
36
+ if next_url and not is_safe_redirect_url(next_url):
37
+ next_url = ""
38
+ google_configured = bool(current_app.config.get("GOOGLE_CLIENT_ID"))
39
+ return render_template("auth/login.html", next=next_url, google_configured=google_configured)
40
+
41
+
42
+ @auth_bp.route("/magic-link", methods=["POST"])
43
+ def request_magic_link():
44
+ client_ip = request.remote_addr or "unknown"
45
+ if not check_rate_limit(f"magic_link_{client_ip}", limit=5, window_seconds=60):
46
+ flash("Too many login requests. Please wait a minute and try again.", "danger")
47
+ return redirect(url_for("auth.login"))
48
+
49
+ email = request.form.get("email", "").strip().lower()
50
+ next_url = request.form.get("next", "")
51
+ if next_url and not is_safe_redirect_url(next_url):
52
+ next_url = ""
53
+
54
+ if not email or "@" not in email:
55
+ flash("Please enter a valid email address.", "warning")
56
+ return redirect(url_for("auth.login", next=next_url))
57
+
58
+ magic_url = create_magic_link(email)
59
+
60
+ # In dev mode without SMTP, pass the magic link URL to the template for quick developer login
61
+ dev_magic_url = None
62
+ if not current_app.config.get("SMTP_SERVER"):
63
+ dev_magic_url = magic_url
64
+
65
+ return render_template(
66
+ "auth/login.html",
67
+ magic_sent=True,
68
+ email=email,
69
+ dev_magic_url=dev_magic_url,
70
+ google_configured=bool(current_app.config.get("GOOGLE_CLIENT_ID")),
71
+ )
72
+
73
+
74
+ @auth_bp.route("/verify-magic", methods=["GET"])
75
+ def verify_magic():
76
+ token_str = request.args.get("token")
77
+ if not token_str:
78
+ flash("Invalid or missing magic link token.", "danger")
79
+ return redirect(url_for("auth.login"))
80
+
81
+ magic_token = MagicLinkToken.query.filter_by(token=token_str).first()
82
+ if not magic_token or magic_token.used:
83
+ flash("This magic link is invalid or has already been used.", "danger")
84
+ return redirect(url_for("auth.login"))
85
+
86
+ # Check expiration (naive/aware comparison handling)
87
+ expires_at = magic_token.expires_at
88
+ if expires_at.tzinfo is None:
89
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
90
+
91
+ if datetime.now(timezone.utc) > expires_at:
92
+ flash("This magic link has expired. Please request a new one.", "warning")
93
+ return redirect(url_for("auth.login"))
94
+
95
+ # Mark token as used
96
+ magic_token.used = True
97
+ db.session.commit()
98
+
99
+ # Find or create user
100
+ user = User.query.filter_by(email=magic_token.email).first()
101
+ if not user:
102
+ # First user created in the system becomes Admin automatically
103
+ user_count = User.query.count()
104
+ user_role = "admin" if user_count == 0 else "user"
105
+ user = User(
106
+ email=magic_token.email,
107
+ name=magic_token.email.split("@")[0].capitalize(),
108
+ role=user_role,
109
+ )
110
+ db.session.add(user)
111
+ db.session.commit()
112
+
113
+ # If non-admin user, assign default permissions for active apps
114
+ if user_role != "admin":
115
+ active_apps = InstalledApp.query.filter_by(is_active=True).all()
116
+ for app in active_apps:
117
+ perm = UserAppPermission(user_id=user.id, app_id=app.id, can_access=True)
118
+ db.session.add(perm)
119
+ db.session.commit()
120
+
121
+ # Track login metrics
122
+ user.last_login_at = datetime.now(timezone.utc)
123
+ user.last_active_at = datetime.now(timezone.utc)
124
+ user.login_count = (user.login_count or 0) + 1
125
+ user.last_ip = request.remote_addr
126
+ db.session.commit()
127
+
128
+ jwt_token = generate_jwt(user)
129
+ flash(f"Welcome back, {user.name}!", "success")
130
+
131
+ next_url = request.args.get("next", "")
132
+ target_redirect = next_url if is_safe_redirect_url(next_url) else url_for("auth.profile")
133
+
134
+ response = make_response(redirect(target_redirect))
135
+
136
+ response.set_cookie(
137
+ JWT_COOKIE_NAME,
138
+ jwt_token,
139
+ httponly=True,
140
+ samesite=current_app.config.get("SESSION_COOKIE_SAMESITE", "Lax"),
141
+ secure=current_app.config.get("SESSION_COOKIE_SECURE", False),
142
+ max_age=7 * 24 * 3600,
143
+ )
144
+ return response
145
+
146
+
147
+ @auth_bp.route("/google")
148
+ def google_login():
149
+ auth_url, error = get_google_auth_url()
150
+ if error:
151
+ flash(f"Google OAuth Error: {error}", "danger")
152
+ return redirect(url_for("auth.login"))
153
+ return redirect(auth_url)
154
+
155
+
156
+ @auth_bp.route("/google/callback")
157
+ def google_callback():
158
+ user_data, error = process_google_callback()
159
+ if error:
160
+ flash(f"Google Login Failed: {error}", "danger")
161
+ return redirect(url_for("auth.login"))
162
+
163
+ email = user_data.get("email", "").lower()
164
+ google_id = user_data.get("sub")
165
+ name = user_data.get("name", email.split("@")[0])
166
+
167
+ if not email:
168
+ flash("Could not retrieve email from Google account.", "danger")
169
+ return redirect(url_for("auth.login"))
170
+
171
+ user = User.query.filter((User.google_id == google_id) | (User.email == email)).first()
172
+ if not user:
173
+ user_count = User.query.count()
174
+ user_role = "admin" if user_count == 0 else "user"
175
+ user = User(email=email, name=name, google_id=google_id, role=user_role)
176
+ db.session.add(user)
177
+ db.session.commit()
178
+ else:
179
+ if not user.google_id:
180
+ user.google_id = google_id
181
+ db.session.commit()
182
+
183
+ jwt_token = generate_jwt(user)
184
+ flash(f"Logged in with Google as {user.email}", "success")
185
+
186
+ response = make_response(redirect(url_for("auth.profile")))
187
+ response.set_cookie(
188
+ JWT_COOKIE_NAME,
189
+ jwt_token,
190
+ httponly=True,
191
+ samesite=current_app.config.get("SESSION_COOKIE_SAMESITE", "Lax"),
192
+ secure=current_app.config.get("SESSION_COOKIE_SECURE", False),
193
+ max_age=7 * 24 * 3600,
194
+ )
195
+ return response
196
+
197
+
198
+ @auth_bp.route("/logout")
199
+ def logout():
200
+ flash("Logged out successfully.", "info")
201
+ response = make_response(redirect(url_for("auth.login")))
202
+ response.delete_cookie(JWT_COOKIE_NAME)
203
+ return response
204
+
205
+
206
+ @auth_bp.route("/profile")
207
+ @login_required
208
+ def profile():
209
+ user = get_current_user()
210
+ if user.is_admin():
211
+ accessible_apps = InstalledApp.query.filter_by(is_active=True).all()
212
+ else:
213
+ accessible_apps = (
214
+ db.session.query(InstalledApp)
215
+ .join(UserAppPermission, UserAppPermission.app_id == InstalledApp.id)
216
+ .filter(
217
+ UserAppPermission.user_id == user.id,
218
+ UserAppPermission.can_access.is_(True),
219
+ InstalledApp.is_active.is_(True),
220
+ )
221
+ .all()
222
+ )
223
+
224
+ return render_template("auth/profile.html", user=user, apps=accessible_apps)
@@ -0,0 +1,175 @@
1
+ import secrets
2
+ import smtplib
3
+ from datetime import datetime, timedelta, timezone
4
+ from email.mime.multipart import MIMEMultipart
5
+ from email.mime.text import MIMEText
6
+ from functools import wraps
7
+
8
+ import jwt
9
+ from flask import current_app, flash, g, redirect, request, url_for
10
+
11
+ from appmanager.database import db
12
+ from appmanager.models import MagicLinkToken, User
13
+
14
+ JWT_COOKIE_NAME = "appmanager_jwt"
15
+
16
+
17
+ def generate_jwt(user):
18
+ payload = {
19
+ "user_id": user.id,
20
+ "email": user.email,
21
+ "role": user.role,
22
+ "exp": datetime.now(timezone.utc) + current_app.config["JWT_ACCESS_TOKEN_EXPIRES"],
23
+ "iat": datetime.now(timezone.utc),
24
+ }
25
+ token = jwt.encode(payload, current_app.config["JWT_SECRET"], algorithm="HS256")
26
+ return token
27
+
28
+
29
+ def decode_jwt(token):
30
+ try:
31
+ payload = jwt.decode(token, current_app.config["JWT_SECRET"], algorithms=["HS256"])
32
+ return payload
33
+ except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
34
+ return None
35
+
36
+
37
+ def get_current_user():
38
+ if hasattr(g, "current_user") and g.current_user is not None:
39
+ return g.current_user
40
+
41
+ token = None
42
+ # 1. Check Authorization header
43
+ auth_header = request.headers.get("Authorization")
44
+ if auth_header and auth_header.startswith("Bearer "):
45
+ token = auth_header.split(" ")[1]
46
+
47
+ # 2. Check HTTP-only cookie
48
+ if not token:
49
+ token = request.cookies.get(JWT_COOKIE_NAME)
50
+
51
+ if not token:
52
+ g.current_user = None
53
+ return None
54
+
55
+ payload = decode_jwt(token)
56
+ if not payload:
57
+ g.current_user = None
58
+ return None
59
+
60
+ user = db.session.get(User, payload["user_id"])
61
+ if user and user.is_active:
62
+ # Update last_active_at timestamp (throttled to once per minute)
63
+ now = datetime.now(timezone.utc)
64
+ last_act = user.last_active_at
65
+ if last_act and last_act.tzinfo is None:
66
+ last_act = last_act.replace(tzinfo=timezone.utc)
67
+ if not last_act or (now - last_act).total_seconds() > 60:
68
+ user.last_active_at = now
69
+ try:
70
+ db.session.commit()
71
+ except Exception:
72
+ db.session.rollback()
73
+
74
+ g.current_user = user
75
+ return user
76
+
77
+ g.current_user = None
78
+ return None
79
+
80
+
81
+ def login_required(f):
82
+ @wraps(f)
83
+ def decorated_function(*args, **kwargs):
84
+ user = get_current_user()
85
+ if not user:
86
+ if request.is_json or request.path.startswith("/api/"):
87
+ return {"error": "Authentication required"}, 401
88
+ return redirect(url_for("auth.login", next=request.url))
89
+ return f(*args, **kwargs)
90
+
91
+ return decorated_function
92
+
93
+
94
+ def admin_required(f):
95
+ @wraps(f)
96
+ def decorated_function(*args, **kwargs):
97
+ user = get_current_user()
98
+ if not user:
99
+ if request.is_json or request.path.startswith("/api/"):
100
+ return {"error": "Authentication required"}, 401
101
+ return redirect(url_for("auth.login", next=request.url))
102
+ if not user.is_admin():
103
+ if request.is_json or request.path.startswith("/api/"):
104
+ return {"error": "Admin privilege required"}, 403
105
+ flash("Admin privilege required to access this area.", "danger")
106
+ return redirect(url_for("auth.profile"))
107
+ return f(*args, **kwargs)
108
+
109
+ return decorated_function
110
+
111
+
112
+ def create_magic_link(email):
113
+ token_str = secrets.token_urlsafe(32)
114
+ expires_at = datetime.now(timezone.utc) + timedelta(
115
+ minutes=current_app.config["MAGIC_LINK_EXPIRES_MINUTES"]
116
+ )
117
+
118
+ # Clean up old unused tokens for this email
119
+ MagicLinkToken.query.filter_by(email=email, used=False).delete()
120
+
121
+ magic_token = MagicLinkToken(email=email, token=token_str, expires_at=expires_at, used=False)
122
+ db.session.add(magic_token)
123
+ db.session.commit()
124
+
125
+ if request:
126
+ base_url = request.host_url.rstrip("/")
127
+ else:
128
+ base_url = current_app.config["APP_BASE_URL"].rstrip("/")
129
+
130
+ verify_url = f"{base_url}/auth/verify-magic?token={token_str}"
131
+
132
+ send_magic_link_email(email, verify_url)
133
+ return verify_url
134
+
135
+
136
+ def send_magic_link_email(email, magic_url):
137
+ smtp_server = current_app.config.get("SMTP_SERVER")
138
+ if smtp_server:
139
+ try:
140
+ msg = MIMEMultipart("alternative")
141
+ msg["Subject"] = "Your Magic Login Link - AppManager"
142
+ msg["From"] = current_app.config["MAIL_DEFAULT_SENDER"]
143
+ msg["To"] = email
144
+
145
+ text = f"Click here to log into AppManager: {magic_url}\nThis link expires in {current_app.config['MAGIC_LINK_EXPIRES_MINUTES']} minutes."
146
+ html = f"""
147
+ <html>
148
+ <body>
149
+ <h2>AppManager Login</h2>
150
+ <p>Click the button below to log in to your account. This link will expire in {current_app.config["MAGIC_LINK_EXPIRES_MINUTES"]} minutes.</p>
151
+ <p><a href="{magic_url}" style="background-color: #4F46E5; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Log In to AppManager</a></p>
152
+ <p>Or copy this link into your browser: <br><code>{magic_url}</code></p>
153
+ </body>
154
+ </html>
155
+ """
156
+ msg.attach(MIMEText(text, "plain"))
157
+ msg.attach(MIMEText(html, "html"))
158
+
159
+ with smtplib.SMTP(smtp_server, current_app.config["SMTP_PORT"]) as server:
160
+ server.starttls()
161
+ server.login(current_app.config["SMTP_USER"], current_app.config["SMTP_PASSWORD"])
162
+ server.send_message(msg)
163
+ print(f"[AUTH] Magic link email successfully sent to {email}")
164
+ except Exception as e:
165
+ print(
166
+ f"[AUTH ERROR] Failed to send email via SMTP: {e}. Falling back to console output."
167
+ )
168
+ print(f"================ MAGIC LINK FOR {email} ================")
169
+ print(f"URL: {magic_url}")
170
+ print("=======================================================")
171
+ else:
172
+ print("\n================ MAGIC LINK (DEV CONSOLE LOG) ================")
173
+ print(f"Target Email: {email}")
174
+ print(f"Login URL: {magic_url}")
175
+ print("==============================================================\n")
appmanager/bridge.py ADDED
@@ -0,0 +1,90 @@
1
+ import json
2
+ from datetime import datetime, timezone
3
+ from typing import Any, Dict, Optional, Union
4
+
5
+ from appmanager.database import db
6
+ from appmanager.models import AppTelemetryLog
7
+ from appmanager.signals import telemetry_received
8
+
9
+
10
+ def report_event(app_slug: str, event_type: str, data: Optional[Dict[str, Any]] = None) -> bool:
11
+ """
12
+ Sub-apps can call report_event to log events directly to AppManager host telemetry.
13
+ Zero-network overhead in-process communication.
14
+ """
15
+ try:
16
+ payload = json.dumps(data) if data is not None else None
17
+ log_entry = AppTelemetryLog(
18
+ app_slug=app_slug,
19
+ event_type=event_type,
20
+ payload_json=payload,
21
+ created_at=datetime.now(timezone.utc),
22
+ )
23
+ db.session.add(log_entry)
24
+ db.session.commit()
25
+
26
+ try:
27
+ telemetry_received.send(None, app_slug=app_slug, event_type=event_type, data=data)
28
+ except Exception:
29
+ pass
30
+
31
+ return True
32
+ except Exception as e:
33
+ db.session.rollback()
34
+ print(f"[BRIDGE ERROR] Failed to record event for '{app_slug}': {e}")
35
+ return False
36
+
37
+
38
+ def report_metric(
39
+ app_slug: str, metric_name: str, value: Union[int, float], unit: Optional[str] = None
40
+ ) -> bool:
41
+ """
42
+ Sub-apps can call report_metric to log numeric metrics.
43
+ """
44
+ data = {"value": value}
45
+ if unit:
46
+ data["unit"] = unit
47
+ return report_event(app_slug, event_type=f"metric:{metric_name}", data=data)
48
+
49
+
50
+ def get_current_user_from_headers(headers: Optional[Any] = None) -> Optional[Dict[str, Any]]:
51
+ """
52
+ Helper for sub-apps to extract the authenticated user identity forwarded by AppManager.
53
+ If headers is None, attempts to read from flask.request.headers or os.environ.
54
+ """
55
+ if headers is None:
56
+ try:
57
+ from flask import request
58
+
59
+ headers = request.headers
60
+ except Exception:
61
+ headers = {}
62
+
63
+ user_id = headers.get("X-AppManager-User-Id") or headers.get("HTTP_X_APPMANAGER_USER_ID")
64
+ user_email = headers.get("X-AppManager-User-Email") or headers.get(
65
+ "HTTP_X_APPMANAGER_USER_EMAIL"
66
+ )
67
+ user_role = headers.get("X-AppManager-User-Role") or headers.get("HTTP_X_APPMANAGER_USER_ROLE")
68
+
69
+ if user_id or user_email:
70
+ return {
71
+ "id": int(user_id) if user_id and str(user_id).isdigit() else user_id,
72
+ "email": user_email,
73
+ "role": user_role or "user",
74
+ }
75
+ return None
76
+
77
+
78
+ def get_app_settings(app_slug: str) -> Dict[str, Any]:
79
+ """
80
+ Helper to retrieve configured settings dictionary for a sub-app.
81
+ """
82
+ from appmanager.models import InstalledApp
83
+
84
+ try:
85
+ app_rec = InstalledApp.query.filter_by(slug=app_slug).first()
86
+ if app_rec:
87
+ return app_rec.get_settings()
88
+ except Exception:
89
+ pass
90
+ return {}