security: Restrict member-only features to MEMBER role
Modules now requiring MEMBER role or higher: - NordaGPT (/chat) - with dedicated landing page for non-members - Wiadomości (/wiadomosci) - private messaging - Tablica B2B (/tablica) - business classifieds - Kontakty (/kontakty) - member contact information Non-members see a promotional page explaining the benefits of NordaGPT membership instead of being simply redirected. This provides clear value proposition for NORDA membership while protecting member-exclusive features. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
579b4636bc
commit
6bf243d1cb
@ -14,10 +14,12 @@ from sqlalchemy import func, desc
|
||||
|
||||
from . import bp
|
||||
from database import (
|
||||
SessionLocal, AIChatConversation, AIChatMessage, AIChatFeedback, AIAPICostLog
|
||||
SessionLocal, AIChatConversation, AIChatMessage, AIChatFeedback, AIAPICostLog,
|
||||
SystemRole
|
||||
)
|
||||
from nordabiz_chat import NordaBizChatEngine
|
||||
from extensions import csrf
|
||||
from utils.decorators import member_required
|
||||
|
||||
# Logger
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -30,13 +32,17 @@ logger = logging.getLogger(__name__)
|
||||
@bp.route('/chat')
|
||||
@login_required
|
||||
def chat():
|
||||
"""AI Chat interface"""
|
||||
"""AI Chat interface - requires MEMBER role"""
|
||||
# SECURITY: NordaGPT is only for members (MEMBER role or higher)
|
||||
if not current_user.has_role(SystemRole.MEMBER):
|
||||
return render_template('chat_members_only.html'), 403
|
||||
return render_template('chat.html')
|
||||
|
||||
|
||||
@bp.route('/api/chat/settings', methods=['GET', 'POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
@member_required
|
||||
def chat_settings():
|
||||
"""Get or update chat settings (model selection, monthly cost)"""
|
||||
if request.method == 'GET':
|
||||
@ -92,6 +98,7 @@ def chat_settings():
|
||||
@bp.route('/api/chat/start', methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
@member_required
|
||||
def chat_start():
|
||||
"""Start new chat conversation"""
|
||||
try:
|
||||
@ -118,6 +125,7 @@ def chat_start():
|
||||
@bp.route('/api/chat/<int:conversation_id>/message', methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_required
|
||||
@member_required
|
||||
def chat_send_message(conversation_id):
|
||||
"""Send message to AI chat"""
|
||||
try:
|
||||
@ -231,6 +239,7 @@ def chat_send_message(conversation_id):
|
||||
|
||||
@bp.route('/api/chat/<int:conversation_id>/history', methods=['GET'])
|
||||
@login_required
|
||||
@member_required
|
||||
def chat_get_history(conversation_id):
|
||||
"""Get conversation history"""
|
||||
try:
|
||||
@ -263,6 +272,7 @@ def chat_get_history(conversation_id):
|
||||
|
||||
@bp.route('/api/chat/conversations', methods=['GET'])
|
||||
@login_required
|
||||
@member_required
|
||||
def chat_list_conversations():
|
||||
"""Get list of user's conversations for sidebar"""
|
||||
db = SessionLocal()
|
||||
@ -293,6 +303,7 @@ def chat_list_conversations():
|
||||
|
||||
@bp.route('/api/chat/<int:conversation_id>/delete', methods=['DELETE'])
|
||||
@login_required
|
||||
@member_required
|
||||
def chat_delete_conversation(conversation_id):
|
||||
"""Delete a conversation"""
|
||||
db = SessionLocal()
|
||||
@ -325,6 +336,7 @@ def chat_delete_conversation(conversation_id):
|
||||
|
||||
@bp.route('/api/chat/feedback', methods=['POST'])
|
||||
@login_required
|
||||
@member_required
|
||||
def chat_feedback():
|
||||
"""API: Submit feedback for AI response"""
|
||||
try:
|
||||
|
||||
@ -13,10 +13,12 @@ from . import bp
|
||||
from database import SessionLocal, Classified, ClassifiedRead, ClassifiedInterest, ClassifiedQuestion, User
|
||||
from sqlalchemy import desc
|
||||
from utils.helpers import sanitize_input
|
||||
from utils.decorators import member_required
|
||||
|
||||
|
||||
@bp.route('/', endpoint='classifieds_index')
|
||||
@login_required
|
||||
@member_required
|
||||
def index():
|
||||
"""Tablica ogłoszeń B2B"""
|
||||
listing_type = request.args.get('type', '')
|
||||
@ -65,6 +67,7 @@ def index():
|
||||
|
||||
@bp.route('/nowe', methods=['GET', 'POST'], endpoint='classifieds_new')
|
||||
@login_required
|
||||
@member_required
|
||||
def new():
|
||||
"""Dodaj nowe ogłoszenie"""
|
||||
if request.method == 'POST':
|
||||
@ -108,6 +111,7 @@ def new():
|
||||
|
||||
@bp.route('/<int:classified_id>', endpoint='classifieds_view')
|
||||
@login_required
|
||||
@member_required
|
||||
def view(classified_id):
|
||||
"""Szczegóły ogłoszenia"""
|
||||
db = SessionLocal()
|
||||
@ -185,6 +189,7 @@ def view(classified_id):
|
||||
|
||||
@bp.route('/<int:classified_id>/zakoncz', methods=['POST'], endpoint='classifieds_close')
|
||||
@login_required
|
||||
@member_required
|
||||
def close(classified_id):
|
||||
"""Zamknij ogłoszenie"""
|
||||
db = SessionLocal()
|
||||
@ -207,6 +212,7 @@ def close(classified_id):
|
||||
|
||||
@bp.route('/<int:classified_id>/delete', methods=['POST'], endpoint='classifieds_delete')
|
||||
@login_required
|
||||
@member_required
|
||||
def delete(classified_id):
|
||||
"""Usuń ogłoszenie (admin only)"""
|
||||
if not current_user.can_access_admin_panel():
|
||||
@ -231,6 +237,7 @@ def delete(classified_id):
|
||||
|
||||
@bp.route('/<int:classified_id>/toggle-active', methods=['POST'], endpoint='classifieds_toggle_active')
|
||||
@login_required
|
||||
@member_required
|
||||
def toggle_active(classified_id):
|
||||
"""Aktywuj/dezaktywuj ogłoszenie (admin only)"""
|
||||
if not current_user.can_access_admin_panel():
|
||||
@ -260,6 +267,7 @@ def toggle_active(classified_id):
|
||||
|
||||
@bp.route('/<int:classified_id>/interest', methods=['POST'], endpoint='classifieds_interest')
|
||||
@login_required
|
||||
@member_required
|
||||
def toggle_interest(classified_id):
|
||||
"""Toggle zainteresowania ogłoszeniem"""
|
||||
db = SessionLocal()
|
||||
@ -312,6 +320,7 @@ def toggle_interest(classified_id):
|
||||
|
||||
@bp.route('/<int:classified_id>/interests', endpoint='classifieds_interests')
|
||||
@login_required
|
||||
@member_required
|
||||
def list_interests(classified_id):
|
||||
"""Lista zainteresowanych (tylko dla autora ogłoszenia)"""
|
||||
db = SessionLocal()
|
||||
@ -357,6 +366,7 @@ def list_interests(classified_id):
|
||||
|
||||
@bp.route('/<int:classified_id>/ask', methods=['POST'], endpoint='classifieds_ask')
|
||||
@login_required
|
||||
@member_required
|
||||
def ask_question(classified_id):
|
||||
"""Zadaj pytanie do ogłoszenia"""
|
||||
db = SessionLocal()
|
||||
@ -405,6 +415,7 @@ def ask_question(classified_id):
|
||||
|
||||
@bp.route('/<int:classified_id>/question/<int:question_id>/answer', methods=['POST'], endpoint='classifieds_answer')
|
||||
@login_required
|
||||
@member_required
|
||||
def answer_question(classified_id, question_id):
|
||||
"""Odpowiedz na pytanie (tylko autor ogłoszenia)"""
|
||||
db = SessionLocal()
|
||||
@ -457,6 +468,7 @@ def answer_question(classified_id, question_id):
|
||||
|
||||
@bp.route('/<int:classified_id>/question/<int:question_id>/hide', methods=['POST'], endpoint='classifieds_hide_question')
|
||||
@login_required
|
||||
@member_required
|
||||
def hide_question(classified_id, question_id):
|
||||
"""Ukryj/pokaż pytanie (tylko autor ogłoszenia)"""
|
||||
db = SessionLocal()
|
||||
@ -495,6 +507,7 @@ def hide_question(classified_id, question_id):
|
||||
|
||||
@bp.route('/<int:classified_id>/questions', endpoint='classifieds_questions')
|
||||
@login_required
|
||||
@member_required
|
||||
def list_questions(classified_id):
|
||||
"""Lista pytań i odpowiedzi do ogłoszenia"""
|
||||
db = SessionLocal()
|
||||
|
||||
@ -13,6 +13,8 @@ from flask import render_template, request, redirect, url_for, flash, current_ap
|
||||
from flask_login import login_required, current_user
|
||||
from sqlalchemy import or_
|
||||
|
||||
from utils.decorators import member_required
|
||||
|
||||
from . import bp
|
||||
from database import SessionLocal, ExternalContact
|
||||
|
||||
@ -21,6 +23,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@bp.route('/', endpoint='contacts_list')
|
||||
@login_required
|
||||
@member_required
|
||||
def list():
|
||||
"""
|
||||
Lista kontaktów zewnętrznych - urzędy, instytucje, partnerzy.
|
||||
@ -95,6 +98,7 @@ def list():
|
||||
|
||||
@bp.route('/<int:contact_id>', endpoint='contact_detail')
|
||||
@login_required
|
||||
@member_required
|
||||
def detail(contact_id):
|
||||
"""
|
||||
Szczegóły kontaktu zewnętrznego - pełna karta osoby.
|
||||
@ -133,6 +137,7 @@ def detail(contact_id):
|
||||
|
||||
@bp.route('/dodaj', methods=['GET', 'POST'], endpoint='contact_add')
|
||||
@login_required
|
||||
@member_required
|
||||
def add():
|
||||
"""
|
||||
Dodawanie nowego kontaktu zewnętrznego.
|
||||
@ -198,6 +203,7 @@ def add():
|
||||
|
||||
@bp.route('/<int:contact_id>/edytuj', methods=['GET', 'POST'], endpoint='contact_edit')
|
||||
@login_required
|
||||
@member_required
|
||||
def edit(contact_id):
|
||||
"""
|
||||
Edycja kontaktu zewnętrznego.
|
||||
@ -267,6 +273,7 @@ def edit(contact_id):
|
||||
|
||||
@bp.route('/<int:contact_id>/usun', methods=['POST'], endpoint='contact_delete')
|
||||
@login_required
|
||||
@member_required
|
||||
def delete(contact_id):
|
||||
"""
|
||||
Usuwanie kontaktu zewnętrznego (soft delete).
|
||||
|
||||
@ -13,6 +13,7 @@ from flask_login import login_required, current_user
|
||||
from . import bp
|
||||
from database import SessionLocal, User, PrivateMessage, UserNotification, UserBlock, Classified
|
||||
from utils.helpers import sanitize_input
|
||||
from utils.decorators import member_required
|
||||
|
||||
|
||||
# ============================================================
|
||||
@ -21,6 +22,7 @@ from utils.helpers import sanitize_input
|
||||
|
||||
@bp.route('/wiadomosci')
|
||||
@login_required
|
||||
@member_required
|
||||
def messages_inbox():
|
||||
"""Skrzynka odbiorcza"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
@ -52,6 +54,7 @@ def messages_inbox():
|
||||
|
||||
@bp.route('/wiadomosci/wyslane')
|
||||
@login_required
|
||||
@member_required
|
||||
def messages_sent():
|
||||
"""Wysłane wiadomości"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
@ -77,6 +80,7 @@ def messages_sent():
|
||||
|
||||
@bp.route('/wiadomosci/nowa')
|
||||
@login_required
|
||||
@member_required
|
||||
def messages_new():
|
||||
"""Formularz nowej wiadomości"""
|
||||
recipient_id = request.args.get('to', type=int)
|
||||
@ -124,6 +128,7 @@ def messages_new():
|
||||
|
||||
@bp.route('/wiadomosci/wyslij', methods=['POST'])
|
||||
@login_required
|
||||
@member_required
|
||||
def messages_send():
|
||||
"""Wyślij wiadomość"""
|
||||
recipient_id = request.form.get('recipient_id', type=int)
|
||||
@ -171,6 +176,7 @@ def messages_send():
|
||||
|
||||
@bp.route('/wiadomosci/<int:message_id>')
|
||||
@login_required
|
||||
@member_required
|
||||
def messages_view(message_id):
|
||||
"""Czytaj wiadomość"""
|
||||
db = SessionLocal()
|
||||
@ -214,6 +220,7 @@ def messages_view(message_id):
|
||||
|
||||
@bp.route('/wiadomosci/<int:message_id>/odpowiedz', methods=['POST'])
|
||||
@login_required
|
||||
@member_required
|
||||
def messages_reply(message_id):
|
||||
"""Odpowiedz na wiadomość"""
|
||||
content = request.form.get('content', '').strip()
|
||||
@ -262,6 +269,7 @@ def messages_reply(message_id):
|
||||
|
||||
@bp.route('/api/messages/unread-count')
|
||||
@login_required
|
||||
@member_required
|
||||
def api_unread_count():
|
||||
"""API: Liczba nieprzeczytanych wiadomości"""
|
||||
db = SessionLocal()
|
||||
@ -281,6 +289,7 @@ def api_unread_count():
|
||||
|
||||
@bp.route('/api/notifications')
|
||||
@login_required
|
||||
@member_required
|
||||
def api_notifications():
|
||||
"""API: Get user notifications"""
|
||||
limit = request.args.get('limit', 20, type=int)
|
||||
@ -330,6 +339,7 @@ def api_notifications():
|
||||
|
||||
@bp.route('/api/notifications/<int:notification_id>/read', methods=['POST'])
|
||||
@login_required
|
||||
@member_required
|
||||
def api_notification_mark_read(notification_id):
|
||||
"""API: Mark notification as read"""
|
||||
db = SessionLocal()
|
||||
@ -355,6 +365,7 @@ def api_notification_mark_read(notification_id):
|
||||
|
||||
@bp.route('/api/notifications/read-all', methods=['POST'])
|
||||
@login_required
|
||||
@member_required
|
||||
def api_notifications_mark_all_read():
|
||||
"""API: Mark all notifications as read"""
|
||||
db = SessionLocal()
|
||||
@ -379,6 +390,7 @@ def api_notifications_mark_all_read():
|
||||
|
||||
@bp.route('/api/notifications/unread-count')
|
||||
@login_required
|
||||
@member_required
|
||||
def api_notifications_unread_count():
|
||||
"""API: Get unread notifications count"""
|
||||
db = SessionLocal()
|
||||
|
||||
169
templates/chat_members_only.html
Normal file
169
templates/chat_members_only.html
Normal file
@ -0,0 +1,169 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}NordaGPT - Dla Członków Izby{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.members-only-container {
|
||||
max-width: 800px;
|
||||
margin: 60px auto;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.members-only-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 24px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.members-only-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--norda-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.members-only-subtitle {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 24px;
|
||||
margin: 40px 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.feature-card-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feature-card-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-card-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
background: linear-gradient(135deg, var(--norda-primary) 0%, #1a5a8a 100%);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
margin-top: 40px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cta-text {
|
||||
opacity: 0.9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background: white;
|
||||
color: var(--norda-primary);
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
color: var(--norda-primary);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
margin-top: 24px;
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="members-only-container">
|
||||
<span class="members-only-icon">🤖</span>
|
||||
|
||||
<h1 class="members-only-title">NordaGPT - Asystent AI dla Członków</h1>
|
||||
|
||||
<p class="members-only-subtitle">
|
||||
NordaGPT to inteligentny asystent, który zna wszystkie firmy członkowskie Izby NORDA.<br>
|
||||
Ta funkcja jest dostępna wyłącznie dla członków stowarzyszenia.
|
||||
</p>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-card-icon">🔍</div>
|
||||
<div class="feature-card-title">Wyszukiwanie firm</div>
|
||||
<div class="feature-card-desc">
|
||||
Znajdź partnerów biznesowych po usługach, kompetencjach lub lokalizacji
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-card-icon">💬</div>
|
||||
<div class="feature-card-title">Naturalna rozmowa</div>
|
||||
<div class="feature-card-desc">
|
||||
Zadawaj pytania w języku naturalnym - AI zrozumie Twoje potrzeby
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-card-icon">📊</div>
|
||||
<div class="feature-card-title">Pełna baza wiedzy</div>
|
||||
<div class="feature-card-desc">
|
||||
Dostęp do aktualności, wydarzeń, ogłoszeń B2B i dyskusji na forum
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-card-icon">🤝</div>
|
||||
<div class="feature-card-title">Rekomendacje</div>
|
||||
<div class="feature-card-desc">
|
||||
Poznaj opinie innych członków o firmach i ich usługach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cta-section">
|
||||
<div class="cta-title">Chcesz korzystać z NordaGPT?</div>
|
||||
<p class="cta-text">
|
||||
Dołącz do Izby Przedsiębiorców NORDA i zyskaj dostęp do asystenta AI oraz wszystkich korzyści członkostwa.
|
||||
</p>
|
||||
<a href="https://norda-biznes.info/dolacz" class="cta-button" target="_blank">
|
||||
Dowiedz się więcej o członkostwie
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('public.index') }}" class="back-link">
|
||||
← Wróć do katalogu firm
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue
Block a user