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:
Maciej Pienczyn 2026-02-01 21:33:27 +01:00
parent 579b4636bc
commit 6bf243d1cb
5 changed files with 215 additions and 2 deletions

View File

@ -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:

View File

@ -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()

View File

@ -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).

View File

@ -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()

View 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 %}